blob: 9e6b6944722f9411a93729deb3738ea26fc6a9cb [file] [log] [blame]
# 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.
"""Compare status of dev and prod builders.
Look at all the builders in a project and check whether dev builder status
matches prod builder status, and provide links when the status disagrees.
"""
from __future__ import annotations
import collections
import datetime
from typing import TYPE_CHECKING
from PB.recipe_engine import result as result_pb2
from PB.go.chromium.org.luci.buildbucket.proto import (
common as common_pb2,
project_config as bb_pb2,
)
from PB.recipes.pigweed.dev_status import InputProperties
from PB.recipe_engine import result
from recipe_engine import post_process
if TYPE_CHECKING: # pragma: no cover
from typing import Generator, Sequence, Tuple
from recipe_engine import recipe_api, recipe_test_api
DEPS = [
'fuchsia/builder_status',
'fuchsia/luci_config',
'recipe_engine/buildbucket',
'recipe_engine/properties',
'recipe_engine/step',
'recipe_engine/time',
]
PROPERTIES = InputProperties
DEFAULT_REQUESTED_BUILDS = 10
DEFAULT_MIN_BUILDS = 4
DEFAULT_MAX_AGE = datetime.timedelta(days=5)
def _make_dev(bucket_name):
"""Add "dev" to bucket_name, if not present."""
parts = bucket_name.split('.')
if 'dev' in parts:
return bucket_name
return '.'.join((*parts[0:-1], 'dev', parts[-1]))
def _make_prod(bucket_name):
"""Remove "dev" from bucket_name, if present."""
parts = bucket_name.split('.')
if 'dev' not in parts:
return bucket_name
return '.'.join(x for x in parts if x != 'dev')
def RunSteps(api: recipe_api.RecipeApi, props: InputProperties):
min_builds = props.min_builds or DEFAULT_MIN_BUILDS
requested_builds = props.requested_builds or DEFAULT_REQUESTED_BUILDS
max_age = props.max_age.ToTimedelta() or DEFAULT_MAX_AGE
bb_cfg: bb_pb2.BuildbucketCfg = api.luci_config.buildbucket()
status: dict[str, dict[str, api.builder_status.BuilderStatus]] = {}
bucket_names = set(x.name for x in bb_cfg.buckets if x.swarming.builders)
with api.step.nest('status'):
for bucket in bb_cfg.buckets:
# If there's not a prod version and a dev version of this bucket
# then don't retrieve status for builders in the bucket. This will
# mostly exclude rolls.
if (
_make_dev(bucket.name) not in bucket_names
or _make_prod(bucket.name) not in bucket_names
):
step = api.step.empty(f'skipping {bucket.name}')
continue
with api.step.nest(bucket.name) as pres:
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(0.1)
builder_status = api.builder_status.retrieve(
bucket=bucket.name,
builder=builder.name,
n=requested_builds,
max_age=max_age,
)
# If there's not much history then ignore this build.
if len(builder_status.builds) < min_builds:
no_builds = api.step.empty('not enough builds')
no_builds.presentation.step_summary_text = (
f'{len(builder_status.builds)} builds, '
f'needed {min_builds}'
)
continue
# If we're adding data to a dev or prod bucket it's
# easiest later if we just always add both buckets to
# the data structure.
status.setdefault(_make_dev(bucket.name), {})
status.setdefault(_make_prod(bucket.name), {})
status[bucket.name][builder.name] = builder_status
failures: list[str] = []
for dev_bucket in sorted(status):
parts = dev_bucket.split('.')
if 'dev' not in parts:
continue
prod_bucket = _make_prod(dev_bucket)
with api.step.nest(dev_bucket) as pres:
for builder in sorted(status[dev_bucket]):
dev = status[dev_bucket][builder]
if builder not in status[prod_bucket]:
continue
prod = status[prod_bucket][builder]
has_recently_passed = api.builder_status.has_recently_passed
if has_recently_passed(dev) != has_recently_passed(prod):
prod_url = api.buildbucket.builder_url(
bucket=prod_bucket, builder=builder
)
dev_url = api.buildbucket.builder_url(
bucket=dev_bucket, builder=builder
)
failures.append(
f'{builder}: '
f'([{prod_bucket}]({prod_url}), '
f'[{dev_bucket}]({dev_url}))'
)
step = api.step.empty(
builder,
status='FAILURE',
raise_on_failure=False,
)
step.presentation.links['dev'] = dev.link
step.presentation.links['prod'] = prod.link
pres.status = 'FAILURE'
if failures:
lines = ['Disagreeing dev builds:', '']
for failure in failures:
lines.append(f'* {failure}')
return result_pb2.RawResult(
summary_markdown='\n'.join(lines),
status=common_pb2.FAILURE,
)
return result_pb2.RawResult(
summary_markdown='All dev builds agree with prod builds',
status=common_pb2.SUCCESS,
)
def GenTests(api) -> Generator[recipe_test_api.TestData, None, None]:
def test(name, *args, **kwargs):
return api.test(
name,
api.buildbucket.ci_build(project='pigweed'),
*args,
**kwargs,
)
def props(min_builds=0, max_age=datetime.timedelta(days=7)):
input_props = InputProperties(min_builds=min_builds)
input_props.max_age.FromTimedelta(max_age)
return api.properties(input_props)
def buildbucket_config(buckets: bb_pb2.Bucket):
cfg = bb_pb2.BuildbucketCfg()
cfg.buckets.extend(buckets)
return cfg
def bucket_config(
name: str,
builders: Sequence[bb_pb2.BuilderConfig],
):
cfg = bb_pb2.Bucket(name=name)
cfg.swarming.builders.extend(builders)
return cfg
def builder_config(name: str):
return bb_pb2.BuilderConfig(name=name)
def mock_buildbucket_config(
*buckets_builders: Sequence[Tuple[str, Sequence[str]]],
):
buckets: List[bb_pb2.Bucket] = []
for bucket_name, builder_names in buckets_builders:
builders: List[bb_pb2.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 build_status(name: str, *statuses: str):
return api.buildbucket.simulated_search_results(
[getattr(api.builder_status, x)() for x in statuses],
step_name=f'status.{name}.buildbucket.search',
)
def skipped(bucket):
return api.post_process(
post_process.MustRun, f'status.skipping {bucket}'
)
def not_enough_builds(bucket, builder):
return api.post_process(
post_process.MustRun,
f'status.{bucket}.{builder}.not enough builds',
)
def enough_builds(bucket, builder):
return api.post_process(
post_process.DoesNotRun,
f'status.{bucket}.{builder}.not enough builds',
)
def passed(bucket):
return api.post_process(post_process.StepSuccess, bucket)
def failed(bucket):
return api.post_process(post_process.StepFailure, bucket)
def not_run(bucket):
return api.post_process(post_process.DoesNotRun, bucket)
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(
'no-runs',
props(min_builds=1),
mock_buildbucket_config(
('try', ('foo', 'bar', 'baz')),
('dev.try', ('foo', 'bar', 'baz')),
('roll', ('foo-roller', 'bar-roller')),
),
not_enough_builds('try', 'foo'),
not_enough_builds('try', 'bar'),
not_enough_builds('try', 'baz'),
not_enough_builds('dev.try', 'foo'),
not_enough_builds('dev.try', 'bar'),
not_enough_builds('dev.try', 'baz'),
skipped('roll'),
not_run('dev.try'),
drop_expectations_must_be_last(),
)
yield test(
'mixed-run-numbers',
mock_buildbucket_config(
('ci', ('foo', 'bar')),
('dev.ci', ('foo', 'bar')),
),
build_status('ci.foo', 'passed', 'passed'),
not_enough_builds('ci', 'foo'),
build_status('ci.bar', *['passed'] * (DEFAULT_MIN_BUILDS + 1)),
enough_builds('ci', 'bar'),
build_status('dev.ci.foo', 'failure', 'failure'),
not_enough_builds('dev.ci', 'foo'),
build_status('dev.ci.bar', *['failure'] * (DEFAULT_MIN_BUILDS + 1)),
enough_builds('dev.ci', 'bar'),
failed('dev.ci'),
not_run('dev.ci.foo'),
failed('dev.ci.bar'),
drop_expectations_must_be_last(),
status='FAILURE',
)
yield test(
'all-passing',
props(min_builds=1),
mock_buildbucket_config(
('ci', ('foo', 'bar', 'baz')),
('dev.ci', ('foo', 'bar', 'baz')),
),
build_status('ci.foo', 'passed', 'passed'),
build_status('ci.bar', 'passed', 'passed'),
build_status('ci.baz', 'passed', 'passed'),
build_status('dev.ci.foo', 'passed', 'passed'),
build_status('dev.ci.bar', 'passed', 'passed'),
build_status('dev.ci.baz', 'passed', 'passed'),
passed('dev.ci'),
drop_expectations_must_be_last(),
)
yield test(
'all-failing',
props(min_builds=1),
mock_buildbucket_config(
('ci', ('foo', 'bar', 'baz')),
('dev.ci', ('foo', 'bar', 'baz')),
),
build_status('ci.foo', 'failure', 'failure'),
build_status('ci.bar', 'failure', 'failure'),
build_status('ci.baz', 'failure', 'failure'),
build_status('dev.ci.foo', 'failure', 'failure'),
build_status('dev.ci.bar', 'failure', 'failure'),
build_status('dev.ci.baz', 'failure', 'failure'),
passed('dev.ci'),
drop_expectations_must_be_last(),
)
yield test(
'mismatch-cifail-devpass',
props(min_builds=1),
mock_buildbucket_config(
('ci', ('foo',)),
('dev.ci', ('foo',)),
),
build_status('ci.foo', 'failure'),
build_status('dev.ci.foo', 'passed'),
failed('dev.ci'),
failed('dev.ci.foo'),
drop_expectations_must_be_last(),
status='FAILURE',
)
yield test(
'mismatch-cipass-devfail',
props(min_builds=1),
mock_buildbucket_config(
('ci', ('foo',)),
('dev.ci', ('foo',)),
),
build_status('ci.foo', 'passed'),
build_status('dev.ci.foo', 'failure'),
failed('dev.ci'),
failed('dev.ci.foo'),
drop_expectations_must_be_last(),
status='FAILURE',
)
yield test(
'prod-dev-only',
props(min_builds=1),
mock_buildbucket_config(
('try', ('foo',)),
('dev.try', ('bar',)),
),
build_status('dev.try.bar', 'failure'),
not_run('dev.try.bar'),
drop_expectations_must_be_last(),
)