blob: 99c1d28adda15aceaff140cb73899f215e2d6392 [file] [log] [blame]
// Copyright 2020 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:buildbucket/buildbucket_pb.dart' as bbv2;
import 'package:cocoon_common/is_release_branch.dart';
import 'package:cocoon_common/task_status.dart';
import 'package:cocoon_server/logging.dart';
import 'package:fixnum/fixnum.dart';
import 'package:github/github.dart' as github;
import 'package:github/github.dart';
import 'package:googleapis/firestore/v1.dart' hide Status;
import 'package:meta/meta.dart';
import '../../cocoon_service.dart';
import '../foundation/github_checks_util.dart';
import '../model/ci_yaml/target.dart';
import '../model/commit_ref.dart';
import '../model/firestore/pr_check_runs.dart' as fs;
import '../model/firestore/task.dart' as fs;
import '../model/github/checks.dart' as cocoon_checks;
import 'exceptions.dart';
import 'luci_build_service/build_tags.dart';
import 'luci_build_service/cipd_version.dart';
import 'luci_build_service/engine_artifacts.dart';
import 'luci_build_service/pending_task.dart';
import 'luci_build_service/user_data.dart';
/// Class to interact with LUCI buildbucket to get, trigger
/// and cancel builds for github repos. It uses [_config.luciTryBuilders] to
/// get the list of available builders.
class LuciBuildService {
LuciBuildService({
required Config config,
required CacheService cache,
required BuildBucketClient buildBucketClient,
required GerritService gerritService,
required PubSub pubsub,
required FirestoreService firestore,
GithubChecksUtil? githubChecksUtil,
}) : _pubsub = pubsub,
_config = config,
_cache = cache,
_buildBucketClient = buildBucketClient,
_githubChecksUtil = githubChecksUtil ?? const GithubChecksUtil(),
_gerritService = gerritService,
_firestore = firestore;
final BuildBucketClient _buildBucketClient;
final CacheService _cache;
final Config _config;
final GithubChecksUtil _githubChecksUtil;
final GerritService _gerritService;
final PubSub _pubsub;
final FirestoreService _firestore;
static const int kBackfillPriority = 35;
static const int kDefaultPriority = 30;
static const int kRerunPriority = 29;
/// LUCI builds that are in merge queues might be retried on flakes.
static const String kMergeQueueKey = 'in_merge_queue';
/// How many times to retry tests in the merge queue.
///
/// Note: the math for max testing is "<" and starts at 1; hence 3 retries.
static const int kMergeQueueMaxRetries = 4;
/// Github labels have a max length of 100, so conserve chars here.
/// This is currently used by packages repo only.
/// See: https://github.com/flutter/flutter/issues/130076
static const String githubBuildLabelPrefix = 'override:';
static const String propertiesGithubBuildLabelName = 'overrides';
/// Name of the subcache to store luci build related values in redis.
static const String subCacheName = 'luci';
// the Request objects here are the BatchRequest object in bbv2.
/// Shards [rows] into several sublists of size [maxEntityGroups].
Future<List<List<bbv2.BatchRequest_Request>>> _shard({
required List<bbv2.BatchRequest_Request> requests,
required int maxShardSize,
}) async {
final shards = <List<bbv2.BatchRequest_Request>>[];
for (var i = 0; i < requests.length; i += maxShardSize) {
shards.add(
requests.sublist(i, i + min<int>(requests.length - i, maxShardSize)),
);
}
return shards;
}
/// Fetches an Iterable of try BuildBucket [Build]s.
///
/// Returns a list of BuildBucket [Build]s for a given Github [PullRequest].
Future<Iterable<bbv2.Build>> getTryBuildsByPullRequest({
required github.PullRequest pullRequest,
}) async {
final slug = pullRequest.base!.repo!.slug();
return _getBuilds(
builderName: null,
bucket: 'try',
tags: BuildTags([
GitHubPullRequestBuildTag(
pullRequestNumber: pullRequest.number!,
slugOwner: slug.owner,
slugName: slug.name,
),
UserAgentBuildTag.flutterCocoon,
]),
);
}
/// Fetches an Iterable of prod BuildBucket [Build]s.
///
/// Returns an Iterable of prod BuildBucket [Build]s for a given
/// [builderName].
Future<Iterable<bbv2.Build>> getProdBuilds({
String? builderName,
String? sha,
}) async {
return _getBuilds(
builderName: builderName,
bucket: 'prod',
tags: BuildTags([
if (sha != null) ByPostsubmitCommitBuildSetBuildTag(commitSha: sha),
// We only want to process (and eventually cancel or retry) jobs started
// by Cocoon; for example, we do not want to cancel or retry manually
// started jobs.
UserAgentBuildTag.flutterCocoon,
]),
);
}
/// Fetches an Iterable of try BuildBucket [Build]s.
///
/// Returns an iterable of try BuildBucket [Build]s for a given
/// [builderName], [bucket], and [tags].
Future<Iterable<bbv2.Build>> _getBuilds({
required String? builderName,
required String bucket,
required BuildTags tags,
}) async {
final fieldMask = bbv2.FieldMask(
paths: {'id', 'builder', 'tags', 'status', 'input.properties'},
);
final buildMask = bbv2.BuildMask(fields: fieldMask);
final buildPredicate = bbv2.BuildPredicate(
builder: bbv2.BuilderID(
project: 'flutter',
bucket: bucket,
builder: builderName,
),
tags: tags.toStringPairs(),
);
final searchBuildsRequest = bbv2.SearchBuildsRequest(
predicate: buildPredicate,
mask: buildMask,
);
// Need to create one of these for each request in the batch.
final batchRequestRequest = bbv2.BatchRequest_Request(
searchBuilds: searchBuildsRequest,
);
final batchResponse = await _buildBucketClient.batch(
bbv2.BatchRequest(requests: {batchRequestRequest}),
);
log.info(
'Responses from get builds batch request = ${batchResponse.responses.length}',
);
for (var response in batchResponse.responses) {
log.info('Found a response: ${response.toString()}');
}
final builds = batchResponse.responses
.map((bbv2.BatchResponse_Response response) => response.searchBuilds)
.expand((bbv2.SearchBuildsResponse? response) => response!.builds);
return builds;
}
/// Checks if [proposedVersion] exists as a recipe branch.
///
/// If it does not, logs and falls back to [CipdVersion.defaultRecipe].
Future<CipdVersion> _getAndCheckRecipeVersion({
required RepositorySlug slug,
required String branch,
}) async {
if (slug != Config.flutterSlug) {
log.debug('Using default recipe: $slug is not flutter/flutter');
return _config.defaultRecipeBundleRef;
}
if (branch == Config.defaultBranch(Config.flutterSlug)) {
log.debug('Using default recipe: $branch is the default branch');
return _config.defaultRecipeBundleRef;
}
final proposed = CipdVersion(branch: branch);
final branches = await _gerritService.branches(
'flutter-review.googlesource.com',
'recipes',
filterRegex: 'flutter-.*|fuchsia.*',
);
if (branches.contains(proposed.version)) {
return proposed;
}
log.warn(
'Falling back to default recipe, could not find "${proposed.version}" '
'in $branches.',
);
return _config.defaultRecipeBundleRef;
}
/// Schedules presubmit [targets] on BuildBucket for [pullRequest].
///
/// [engineArtifacts] determines how framework tests download and use the Flutter engine by
/// providing `FLUTTER_PREBUILT_ENGINE_VERISON` if set. For builds that are not running
/// framework tests, provide [EngineArtifacts.noFrameworkTests].
Future<List<Target>> scheduleTryBuilds({
required List<Target> targets,
required github.PullRequest pullRequest,
required EngineArtifacts engineArtifacts,
}) async {
if (targets.isEmpty) {
return targets;
}
final batchRequestList = <bbv2.BatchRequest_Request>[];
final commitSha = pullRequest.head!.sha!;
final isFusion = pullRequest.base!.repo!.slug() == Config.flutterSlug;
final cipdVersion = await _getAndCheckRecipeVersion(
slug: pullRequest.base!.repo!.slug(),
branch: pullRequest.base!.ref!,
);
final checkRuns = <github.CheckRun>[];
for (var target in targets) {
final checkRun = await _githubChecksUtil.createCheckRun(
_config,
target.slug,
commitSha,
target.name,
);
checkRuns.add(checkRun);
final slug = pullRequest.base!.repo!.slug();
final commitBranch = pullRequest.base!.ref!.replaceAll('refs/heads/', '');
final userData = PresubmitUserData(
commit: CommitRef(slug: slug, sha: commitSha, branch: commitBranch),
checkRunId: checkRun.id!,
checkSuiteId: checkRun.checkSuiteId!,
);
final properties = target.getProperties();
properties.putIfAbsent('git_branch', () => commitBranch);
final struct = bbv2.Struct.create();
struct.mergeFromProto3Json(properties);
final labels = _extractPrefixedLabels(
issueLabels: pullRequest.labels,
prefix: githubBuildLabelPrefix,
);
if (labels != null && labels.isNotEmpty) {
properties[propertiesGithubBuildLabelName] = labels;
log.info(
'Found overrides: labels for PR#${pullRequest.number}: $labels.',
);
}
if (isFusion) {
// Fusion *also* means "this is flutter/flutter", so determine how to specify the engine version and realm.
switch (engineArtifacts) {
case SpecifiedEngineArtifacts(:final commitSha, :final flutterRealm):
properties.addAll({
// If we've touched the engine, we must use the current git sha's uploaded engine artifacts.
// Otherwise, let the flutter tool find the previously built artfiacts (using content-aware hashing).
// TODO(matanlurey): Review with jtmcdole to ensure this is correct.
if (engineArtifacts.isBuiltFromSource)
'flutter_prebuilt_engine_version': commitSha,
'flutter_realm': flutterRealm,
});
case UnnecessaryEngineArtifacts(:final reason):
log.debug(
'No engineArtifacts were specified for PR#${pullRequest.number} (${pullRequest.head!.sha}): $reason.',
);
}
} else if (engineArtifacts is! UnnecessaryEngineArtifacts) {
// This is an error case, as we're setting artifacts for a PR that will never use them.
throw StateError(
'Unexpected engineArtifacts were specified for PR#${pullRequest.number} (${pullRequest.head!.sha})',
);
}
final requestedDimensions = target.getDimensions();
batchRequestList.add(
bbv2.BatchRequest_Request(
scheduleBuild: _createPresubmitScheduleBuild(
slug: slug,
sha: pullRequest.head!.sha!,
//Use target.value.name here otherwise tests will die due to null checkRun.name.
checkName: target.name,
pullRequestNumber: pullRequest.number!,
cipdVersion: cipdVersion,
userData: userData,
properties: properties,
tags: BuildTags([
GitHubCheckRunIdBuildTag(checkRunId: checkRun.id!),
]),
dimensions: requestedDimensions,
),
),
);
}
// All check runs created, now record them in firestore so we can
// figure out which PR started what check run later (e.g. check_run completed).
try {
final doc = await fs.PrCheckRuns.initializeDocument(
firestoreService: _firestore,
pullRequest: pullRequest,
checks: checkRuns,
);
log.info('scheduleTryBuilds: created PrCheckRuns doc ${doc.name}');
} catch (e, s) {
// We are not going to block on this error. If we cannot find this document
// later, we'll fall back to the old github query method.
log.warn('scheduleTryBuilds: error creating PrCheckRuns doc', e, s);
}
final Iterable<List<bbv2.BatchRequest_Request>> requestPartitions =
await _shard(
requests: batchRequestList,
maxShardSize: _config.schedulingShardSize,
);
for (var requestPartition in requestPartitions) {
final batchRequest = bbv2.BatchRequest(requests: requestPartition);
await _pubsub.publish(
'cocoon-scheduler-requests',
batchRequest.toProto3Json(),
);
}
return targets;
}
/// Cancels all the current builds on [pullRequest] with [reason].
Future<void> cancelBuilds({
required github.PullRequest pullRequest,
required String reason,
}) async {
log.info(
'Attempting to cancel builds (v2) for pullrequest ${pullRequest.base!.repo!.fullName}/${pullRequest.number}',
);
final builds = await getTryBuildsByPullRequest(pullRequest: pullRequest);
if (builds.isEmpty) {
log.info(
'No builds were found for pull request ${pullRequest.base!.repo!.fullName}.',
);
return;
}
log.info('Found ${builds.length} builds.');
final requests = <bbv2.BatchRequest_Request>[];
for (var build in builds) {
if (build.status == bbv2.Status.SCHEDULED ||
build.status == bbv2.Status.STARTED) {
// Scheduled status includes scheduled and pending tasks.
log.info('Cancelling build with build id ${build.id}.');
requests.add(
bbv2.BatchRequest_Request(
cancelBuild: bbv2.CancelBuildRequest(
id: build.id,
summaryMarkdown: reason,
),
),
);
}
}
if (requests.isNotEmpty) {
await _buildBucketClient.batch(bbv2.BatchRequest(requests: requests));
}
}
/// Cancels all the current builds against the give [sha] with [reason].
Future<void> cancelBuildsBySha({
required String sha,
required String reason,
}) async {
log.info(
'Attempting to cancel builds (v2) for git SHA $sha because $reason',
);
final builds = await getProdBuilds(sha: sha);
if (builds.isEmpty) {
log.info('No builds found. Will not request cancellation from LUCI.');
return;
}
log.info('Found ${builds.length} builds.');
final requests = <bbv2.BatchRequest_Request>[];
for (final build in builds) {
if (build.status == bbv2.Status.SCHEDULED ||
build.status == bbv2.Status.STARTED) {
// Scheduled status includes scheduled and pending tasks.
log.info('Cancelling build with build id ${build.id}.');
requests.add(
bbv2.BatchRequest_Request(
cancelBuild: bbv2.CancelBuildRequest(
id: build.id,
summaryMarkdown: reason,
),
),
);
}
}
if (requests.isNotEmpty) {
await _buildBucketClient.batch(bbv2.BatchRequest(requests: requests));
}
}
/// Sends [ScheduleBuildRequest] using information from a given build's
/// [BuildPushMessage].
///
/// The buildset, user_agent, and github_link tags are applied to match the
/// original build. The build properties and user data from the original build
/// are also preserved.
///
/// The [currentAttempt] is used to track the number of current build attempt.
Future<bbv2.Build> reschedulePresubmitBuild({
required String builderName,
required bbv2.Build build,
required int nextAttempt,
required PresubmitUserData userData,
}) async {
final tags = BuildTags.fromStringPairs(build.tags);
tags.addOrReplace(CurrentAttemptBuildTag(attemptNumber: nextAttempt));
final request = bbv2.ScheduleBuildRequest(
builder: build.builder,
tags: tags.toStringPairs(),
properties: build.input.properties,
notify: bbv2.NotificationConfig(
pubsubTopic: 'projects/flutter-dashboard/topics/build-bucket-presubmit',
userData: userData.toBytes(),
),
);
if (build.input.hasGitilesCommit()) {
request.gitilesCommit = build.input.gitilesCommit;
}
return _buildBucketClient.scheduleBuild(request);
}
/// Collect any label whose name is prefixed by the prefix [String].
///
/// Returns a [List] of prefixed label names as [String]s.
static List<String>? _extractPrefixedLabels({
List<github.IssueLabel>? issueLabels,
required String prefix,
}) {
return issueLabels
?.where((label) => label.name.startsWith(prefix))
.map((obj) => obj.name)
.toList();
}
/// Sends postsubmit [ScheduleBuildRequest] for a commit using [checkRunEvent], [Commit], [Task], and [Target].
Future<void> reschedulePostsubmitBuildUsingCheckRunEvent(
cocoon_checks.CheckRunEvent checkRunEvent, {
required CommitRef commit,
required Target target,
required fs.Task task,
}) async {
final checkName = checkRunEvent.checkRun!.name!;
final builds = await getProdBuilds(builderName: checkName);
if (builds.isEmpty) {
throw NoBuildFoundException('Unable to find prod build.');
}
final build = builds.first;
// get it as a struct first and convert it.
final propertiesStruct = build.input.properties;
final properties = propertiesStruct.toProto3Json() as Map<String, Object?>;
final tags = BuildTags.fromStringPairs(build.tags);
log.info('input ${build.input} properties $properties');
log.info('input ${build.input} tags $tags');
tags.addOrReplace(TriggerTypeBuildTag.checkRunManualRetry);
final int newAttempt;
try {
newAttempt = await _updateTaskStatusInDatabaseForRetry(commit, task);
} catch (e, s) {
log.error(
'updating task ${task.taskName} of commit '
'${task.commitSha}. Skipping rescheduling.',
e,
s,
);
return;
}
log.info('Updated input ${build.input} tags $tags');
final request = bbv2.BatchRequest(
requests: <bbv2.BatchRequest_Request>[
bbv2.BatchRequest_Request(
scheduleBuild: await _createPostsubmitScheduleBuild(
commit: commit,
target: target,
taskName: task.taskName,
properties: properties,
priority: kRerunPriority,
tags: tags,
currentAttempt: newAttempt,
),
),
],
);
await _pubsub.publish('cocoon-scheduler-requests', request.toProto3Json());
}
/// Gets [bbv2.Build] using its [id] and passing the additional
/// fields to be populated in the response.
Future<bbv2.Build> getBuildById(Int64 id, {bbv2.BuildMask? buildMask}) async {
final request = bbv2.GetBuildRequest(id: id, mask: buildMask);
return _buildBucketClient.getBuild(request);
}
/// Gets builder list whose config is pre-defined in LUCI.
///
/// Returns cache if existing. Otherwise make the RPC call to fetch list.
Future<Set<String>> getAvailableBuilderSet({
String project = 'flutter',
String bucket = 'prod',
}) async {
final cacheValue = await _cache.getOrCreate(
subCacheName,
'builderlist/$project/$bucket',
createFn: () => _getAvailableBuilderSet(project: project, bucket: bucket),
// New commit triggering tasks should be finished within 5 mins.
// The batch backfiller's execution frequency is also 5 mins.
ttl: const Duration(minutes: 5),
);
return Set.from(String.fromCharCodes(cacheValue!).split(','));
}
/// Returns cache if existing, otherwise makes the RPC call to fetch list.
///
/// Use [token] to make sure obtain all the list by calling RPC multiple times.
Future<Uint8List> _getAvailableBuilderSet({
String project = 'flutter',
String bucket = 'prod',
}) async {
log.info(
'No cached value for builderList, start fetching via the rpc call.',
);
final availableBuilderSet = <String>{};
var hasToken = true;
String? token;
do {
final listBuildersResponse = await _buildBucketClient.listBuilders(
bbv2.ListBuildersRequest(
project: project,
bucket: bucket,
pageToken: token,
),
);
final availableBuilderList = listBuildersResponse.builders
.map((e) => e.id.builder)
.toList();
availableBuilderSet.addAll(<String>{...availableBuilderList});
hasToken = listBuildersResponse.hasNextPageToken();
if (hasToken) {
token = listBuildersResponse.nextPageToken;
}
} while (hasToken && token != null);
final joinedBuilderSet = availableBuilderSet.toList().join(',');
log.info('successfully fetched the builderSet: $joinedBuilderSet');
return Uint8List.fromList(joinedBuilderSet.codeUnits);
}
/// Schedules list of post-submit builds deferring work to [schedulePostsubmitBuild].
///
/// Returns empty list if all targets are successfully published to pub/sub. Otherwise,
/// returns the original list.
@useResult
Future<List<PendingTask>> schedulePostsubmitBuilds({
required CommitRef commit,
required List<PendingTask> toBeScheduled,
String? contentHash,
}) async {
if (toBeScheduled.isEmpty) {
log.debug(
'Skipping schedulePostsubmitBuilds as there are no targets to be '
'scheduled by Cocoon',
);
return toBeScheduled;
}
final buildRequests = <bbv2.BatchRequest_Request>[];
// bbv2.BatchRequest_Request batchRequest_Request = bbv2.BatchRequest_Request();
Set<String> availableBuilderSet;
try {
availableBuilderSet = await getAvailableBuilderSet(
project: 'flutter',
bucket: 'prod',
);
} catch (e) {
log.error('Failed to get buildbucket builder list', e);
return toBeScheduled;
}
for (var pending in toBeScheduled) {
// Non-existing builder target will be skipped from scheduling.
if (!availableBuilderSet.contains(pending.target.name)) {
log.warn(
'Found no available builder for ${pending.target.name}, commit ${commit.sha}',
);
continue;
}
log.info(
'create postsubmit schedule request for target: ${pending.target} in commit ${commit.sha}',
);
final properties = <String, Object>{
if (contentHash != null) 'content_hash': contentHash,
};
final scheduleBuildRequest = await _createPostsubmitScheduleBuild(
commit: commit,
target: pending.target,
taskName: pending.taskName,
priority: pending.priority,
currentAttempt: pending.currentAttempt,
properties: properties,
);
buildRequests.add(
bbv2.BatchRequest_Request(scheduleBuild: scheduleBuildRequest),
);
log.info(
'created postsubmit schedule request for target: ${pending.target} in commit ${commit.sha}',
);
}
final batchRequest = bbv2.BatchRequest(requests: buildRequests);
log.debug('$batchRequest');
List<String> messageIds;
try {
messageIds = await _pubsub.publish(
'cocoon-scheduler-requests',
batchRequest.toProto3Json(),
);
log.info('Published $messageIds for commit ${commit.sha}');
} catch (e) {
log.error('Failed to publish message to pub/sub', e);
return toBeScheduled;
}
log.info('Published a request with ${buildRequests.length} builds');
return <PendingTask>[];
}
/// Schedules [targets] for building of prod artifacts while in a merge queue.
Future<void> scheduleMergeGroupBuilds({
required CommitRef commit,
required List<Target> targets,
String? contentHash,
}) async {
final buildRequests = <bbv2.BatchRequest_Request>[];
final Set<String> availableBuilderSet;
try {
availableBuilderSet = await getAvailableBuilderSet(
project: 'flutter',
bucket: 'prod',
);
} catch (e) {
log.warn('Failed to get buildbucket builder list', e);
throw 'Failed to get buildbucket builder list due to $e';
}
for (var target in targets) {
// Non-existing builder target will be skipped from scheduling.
if (!availableBuilderSet.contains(target.name)) {
log.warn(
'Found no available builder for ${target.name}, commit '
'${commit.sha}',
);
continue;
}
log.info(
'create postsubmit schedule request for target: $target in commit ${commit.sha}',
);
final properties = <String, Object>{
if (contentHash != null) 'content_hash': contentHash,
};
final scheduleBuildRequest = await _createMergeGroupScheduleBuild(
commit: commit,
target: target,
properties: properties,
);
buildRequests.add(
bbv2.BatchRequest_Request(scheduleBuild: scheduleBuildRequest),
);
log.info(
'created postsubmit schedule request for target: $target in commit ${commit.sha}',
);
}
final batchRequest = bbv2.BatchRequest(requests: buildRequests);
log.debug('$batchRequest');
final List<String> messageIds;
try {
messageIds = await _pubsub.publish(
'cocoon-scheduler-requests',
batchRequest.toProto3Json(),
);
log.info('Published $messageIds for commit ${commit.sha}');
} catch (e) {
log.error('Failed to publish message to pub/sub', e);
rethrow;
}
log.info('Published a request with ${buildRequests.length} builds');
}
/// Create a Presubmit ScheduleBuildRequest using the [slug], [sha], and
/// [checkName] for the provided [build] with the provided [checkRunId].
bbv2.ScheduleBuildRequest _createPresubmitScheduleBuild({
required github.RepositorySlug slug,
required String sha,
required String checkName,
required int pullRequestNumber,
required CipdVersion cipdVersion,
required PresubmitUserData userData,
Map<String, Object?>? properties,
BuildTags? tags,
List<bbv2.RequestedDimension>? dimensions,
}) {
final builderId = bbv2.BuilderID.create();
builderId.bucket = 'try';
builderId.project = 'flutter';
builderId.builder = checkName;
// Add the builderId.
final scheduleBuildRequest = bbv2.ScheduleBuildRequest.create();
scheduleBuildRequest.builder = builderId;
final fields = <String>['id', 'builder', 'number', 'status', 'tags'];
final fieldMask = bbv2.FieldMask(paths: fields);
final buildMask = bbv2.BuildMask(fields: fieldMask);
scheduleBuildRequest.mask = buildMask;
// Set the executable.
final executable = bbv2.Executable(cipdVersion: cipdVersion.version);
scheduleBuildRequest.exe = executable;
// Add the dimensions to the instance.
final instanceDimensions = scheduleBuildRequest.dimensions;
instanceDimensions.addAll(dimensions ?? []);
// Create the notification configuration for pubsub processing.
final notificationConfig = bbv2.NotificationConfig().createEmptyInstance();
notificationConfig.pubsubTopic =
'projects/flutter-dashboard/topics/build-bucket-presubmit';
notificationConfig.userData = userData.toBytes();
scheduleBuildRequest.notify = notificationConfig;
// If we received initial tags, create a defensive copy, otherwise create an empty list.
tags = tags?.clone() ?? BuildTags();
tags.addAll([
ByPresubmitCommitBuildSetBuildTag(commitSha: sha),
UserAgentBuildTag.flutterCocoon,
GitHubPullRequestBuildTag(
slugOwner: slug.owner,
slugName: slug.name,
pullRequestNumber: pullRequestNumber,
),
CipdVersionBuildTag(cipdVersion),
]);
scheduleBuildRequest.tags.addAll(tags.toStringPairs());
properties ??= {};
properties['git_url'] = 'https://github.com/${slug.owner}/${slug.name}';
properties['git_ref'] = 'refs/pull/$pullRequestNumber/head';
properties['git_repo'] = slug.name;
properties['exe_cipd_version'] = cipdVersion.version;
final propertiesStruct = bbv2.Struct.create();
propertiesStruct.mergeFromProto3Json(properties);
scheduleBuildRequest.properties = propertiesStruct;
return scheduleBuildRequest;
}
/// Creates a [ScheduleBuildRequest] for [target] and [task] against [commit].
///
/// By default, build [priority] is increased for release branches.
Future<bbv2.ScheduleBuildRequest> _createPostsubmitScheduleBuild({
required CommitRef commit,
required Target target,
required String taskName,
required int currentAttempt,
Map<String, Object?>? properties,
BuildTags? tags,
int priority = kDefaultPriority,
}) async {
log.info(
'Creating postsubmit schedule builder for ${target.name} on commit ${commit.sha}',
);
tags ??= BuildTags([
ByPostsubmitCommitBuildSetBuildTag(commitSha: commit.sha),
ByCommitMirroredBuildSetBuildTag(
commitSha: commit.sha,
slugName: commit.slug.name,
),
]);
// Creates post submit checkrun only for unflaky targets from [config.postsubmitSupportedRepos].
final CheckRun? checkRun;
if (!target.isBringup &&
_config.postsubmitSupportedRepos.contains(target.slug)) {
checkRun = await createPostsubmitCheckRun(commit, target);
} else {
checkRun = null;
}
tags.addOrReplace(UserAgentBuildTag.flutterCocoon);
tags.addOrReplace(SchedulerJobIdBuildTag(targetName: target.name));
tags.addOrReplace(CurrentAttemptBuildTag(attemptNumber: currentAttempt));
final firestoreTask = fs.TaskId(
commitSha: commit.sha,
taskName: taskName,
currentAttempt: currentAttempt,
);
final userData = PostsubmitUserData(
taskId: firestoreTask,
checkRunId: checkRun?.id,
);
final processedProperties = target.getProperties().cast<String, Object?>();
if (properties != null) processedProperties.addAll(properties);
processedProperties['git_branch'] = commit.branch;
processedProperties['git_repo'] = commit.slug.name;
final cipdVersion = await _getAndCheckRecipeVersion(
slug: commit.slug,
branch: commit.branch,
);
processedProperties['exe_cipd_version'] = cipdVersion.version;
final isFusion = commit.slug == Config.flutterSlug;
if (isFusion) {
if (commit.branch != Config.defaultBranch(Config.flutterSlug)) {
processedProperties.addAll({
// For release candidates, let the flutter tool pick the right engine.
if (!isReleaseCandidateBranch(branchName: commit.branch))
'flutter_prebuilt_engine_version': commit.sha,
// Prod build bucket, built during the merge queue.
'flutter_realm': '',
});
}
}
final propertiesStruct = bbv2.Struct.create();
propertiesStruct.mergeFromProto3Json(processedProperties);
final requestedDimensions = target.getDimensions();
final executable = bbv2.Executable(cipdVersion: cipdVersion.version);
log.info(
'Constructing the postsubmit schedule build request for ${target.name} on commit ${commit.sha}.',
);
return bbv2.ScheduleBuildRequest(
builder: bbv2.BuilderID(
project: 'flutter',
bucket: target.getBucket(),
builder: target.name,
),
dimensions: requestedDimensions,
exe: executable,
gitilesCommit: bbv2.GitilesCommit(
project: 'mirrors/${commit.slug.name}',
host: 'flutter.googlesource.com',
ref: 'refs/heads/${commit.branch}',
id: commit.sha,
),
notify: bbv2.NotificationConfig(
pubsubTopic:
'projects/flutter-dashboard/topics/build-bucket-postsubmit',
userData: userData.toBytes(),
),
tags: tags.toStringPairs(),
properties: propertiesStruct,
priority: priority,
);
}
/// Creates a build request for a commit in a merge queue which will notify
/// presubmit channels.
Future<bbv2.ScheduleBuildRequest> _createMergeGroupScheduleBuild({
required CommitRef commit,
required Target target,
int priority = kDefaultPriority,
Map<String, Object?>? properties,
}) async {
log.info(
'Creating merge group schedule builder for ${target.name} on commit ${commit.sha}',
);
log.info('Scheduling builder: ${target.name} for commit ${commit.sha}');
final checkRun = await createPostsubmitCheckRun(commit, target);
final preUserData = PresubmitUserData(
commit: commit,
checkRunId: checkRun.id!,
checkSuiteId: checkRun.checkSuiteId!,
);
final processedProperties = target.getProperties().cast<String, Object?>();
processedProperties['git_branch'] = commit.branch;
final mqBranch = tryParseGitHubMergeQueueBranch(commit.branch);
log.info('parsed mqBranch: $mqBranch');
final cipdExe = 'refs/heads/${mqBranch.branch}';
processedProperties['exe_cipd_version'] = cipdExe;
processedProperties[kMergeQueueKey] = true;
processedProperties['git_repo'] = commit.slug.name;
if (properties != null) processedProperties.addAll(properties);
final propertiesStruct = bbv2.Struct()
..mergeFromProto3Json(processedProperties);
final requestedDimensions = target.getDimensions();
final executable = bbv2.Executable(cipdVersion: cipdExe);
log.info(
'Constructing the merge group schedule build request for ${target.name} on commit ${commit.sha}.',
);
return bbv2.ScheduleBuildRequest(
builder: bbv2.BuilderID(
project: 'flutter',
bucket: target.getBucket(),
builder: target.name,
),
dimensions: requestedDimensions,
exe: executable,
gitilesCommit: bbv2.GitilesCommit(
project: 'mirrors/${commit.slug.name}',
host: 'flutter.googlesource.com',
ref: 'refs/heads/${commit.branch}',
id: commit.sha,
),
notify: bbv2.NotificationConfig(
// IMPORTANT: We're not post-submit yet, so we want to handle updates to
// the MQ differently.
pubsubTopic: 'projects/flutter-dashboard/topics/build-bucket-presubmit',
userData: preUserData.toBytes(),
),
tags: BuildTags([
ByPostsubmitCommitBuildSetBuildTag(commitSha: commit.sha),
ByCommitMirroredBuildSetBuildTag(
commitSha: commit.sha,
slugName: commit.slug.name,
),
UserAgentBuildTag.flutterCocoon,
SchedulerJobIdBuildTag(targetName: target.name),
CurrentAttemptBuildTag(attemptNumber: 1),
InMergeQueueBuildTag(),
]).toStringPairs(),
properties: propertiesStruct,
priority: priority,
);
}
/// Creates postsubmit check runs for prod targets in supported repositories.
@useResult
Future<CheckRun> createPostsubmitCheckRun(
CommitRef commit,
Target target,
) async {
// We are not tracking this check run in the PrCheckRuns firestore doc because
// there is no PR to look up later. The check run is important because it
// informs the staging document setup for Merge Groups in triggerMergeGroupTargets.
return _githubChecksUtil.createCheckRun(
_config,
target.slug,
commit.sha,
target.name,
);
}
/// Reruns the provided [task], returning `true` if successful.
@useResult
Future<bool> rerunBuilder({
required CommitRef commit,
required Target target,
required fs.Task task,
Iterable<BuildTag> tags = const [],
}) async {
log.info('Rerun builder: ${target.name} for commit ${commit.sha}');
final buildTags = BuildTags(tags);
buildTags.add(TriggerTypeBuildTag.autoRetry);
final int newAttempt;
try {
newAttempt = await _updateTaskStatusInDatabaseForRetry(commit, task);
} catch (e, s) {
log.error(
'Updating task ${task.taskName} of commit '
'${task.commitSha} failure. Skipping rescheduling.',
e,
s,
);
return false;
}
log.info('Tags from rerun after update: $tags');
final request = bbv2.BatchRequest(
requests: <bbv2.BatchRequest_Request>[
bbv2.BatchRequest_Request(
scheduleBuild: await _createPostsubmitScheduleBuild(
commit: commit,
target: target,
taskName: task.taskName,
priority: kRerunPriority,
properties: Config.defaultProperties,
tags: buildTags,
currentAttempt: newAttempt,
),
),
],
);
await _pubsub.publish('cocoon-scheduler-requests', request.toProto3Json());
return true;
}
/// Updates the status of [task] in the database to reflect that it is being
/// re-run, and returns the new attempt number.
@useResult
Future<int> _updateTaskStatusInDatabaseForRetry(
CommitRef commit,
fs.Task task,
) async {
// Update task status in Firestore.
task.resetAsRetry();
task.setStatus(TaskStatus.inProgress);
await _firestore.batchWriteDocuments(
BatchWriteRequest(writes: documentsToWrites([task], exists: false)),
kDatabase,
);
return task.currentAttempt;
}
/// Builder is defined in dart-internal:
/// https://dart-internal.googlesource.com/dart-internal/+/ab97fef445a9e415b504b9398cd2406c9c42ea27/flutter-internal/flutter.star#33
static const _releaseBuilderName = 'Linux flutter_release_builder';
/// Reruns `Linux flutter_release_builder` for a release candidate [commit].
///
/// Returns `false` if a rerun was not scheduled.
Future<bool> rerunDartInternalReleaseBuilder({
required CommitRef commit,
required fs.Task task,
}) async {
log.debug(
'rerunDartInternalReleaseBuilder(buildNumber=${task.buildNumber} for $commit)',
);
final builderId = bbv2.BuilderID(
project: 'dart-internal',
bucket: 'flutter',
builder: _releaseBuilderName,
);
// We need to first look up: what is the full build ID given a build number?
final bbv2.Build build;
try {
build = await _buildBucketClient.getBuild(
bbv2.GetBuildRequest(buildNumber: task.buildNumber, builder: builderId),
);
} on BuildBucketException catch (e) {
if (e.statusCode == 404) {
log.error('No build found for ${task.buildNumber} in $builderId');
return false;
}
rethrow;
}
// Because this is a large orchestrator build (a build that schedules many other sub-builds), and it is
// unlikely that every single build failed, we want to take advantage of the "retry_override_list" optional
// property, if able:
// https://flutter.googlesource.com/recipes/+/refs/heads/main/recipes/release/release_builder.py#162
final search = await _buildBucketClient.searchBuilds(
bbv2.SearchBuildsRequest(
predicate: bbv2.BuildPredicate(childOf: build.id),
// build.name is not available by default unless requested (http://shortn/_JMHFmMhfPn)
mask: bbv2.BuildMask(
inputProperties: [
bbv2.StructMask(path: const ['build', 'name']),
bbv2.StructMask(path: const ['config_name']),
],
),
),
);
if (search.builds.isEmpty) {
log.warn(
'No builds found for ${build.id}. This can occur when the previous '
'build completely failed, i.e. no builds were successfully spawned. A '
'full rebuild will be triggered',
);
}
final failedEngineBuilds = [..._filterFailedEngineBuilds(search.builds)];
if (failedEngineBuilds.isEmpty) {
log.info(
'No failing engine builds found for ${build.id}, will rerun all builds',
);
} else {
log.debug(
'Re-running specific engine builds: ${failedEngineBuilds.join(', ')}',
);
}
final result = await _buildBucketClient.scheduleBuild(
bbv2.ScheduleBuildRequest(
builder: builderId,
// Release builds intentionally use ToT for recipe branches.
exe: null,
gitilesCommit: build.input.gitilesCommit,
tags: build.tags,
// Explicitly omitted. We don't want a custom callback, and instead will
// rely on the (automatic) callback that happens as part of the dart-interrnal
// LUCI configuration:
// https://dart-internal.googlesource.com/dart-internal/+/ab97fef445a9e415b504b9398cd2406c9c42ea27/main.star#31
notify: null,
// See https://flutter.googlesource.com/recipes/+/58bceb87e4a3d3b60e7f148c082eb262db7fd4bb/recipes/release/release_builder.py#162.
properties: bbv2.Struct(
fields: {
// Use the existing build inputs to run in the same state.
...build.input.properties.fields,
if (failedEngineBuilds.isNotEmpty)
'retry_override_list': bbv2.Value(
stringValue: failedEngineBuilds.join(' '),
),
},
),
priority: kRerunPriority,
),
);
// Mark the task is in progress.
final attempt = await _updateTaskStatusInDatabaseForRetry(commit, task);
log.info('Scheduled build (attempt #$attempt): $result');
return true;
}
static Iterable<String> _filterFailedEngineBuilds(
Iterable<bbv2.Build> builds,
) => builds
.where((b) {
final failed = const {
bbv2.Status.FAILURE,
bbv2.Status.INFRA_FAILURE,
bbv2.Status.CANCELED,
}.contains(b.status);
return failed;
})
.map((b) {
return b.input.properties.fields['config_name']?.stringValue;
})
.nonNulls;
}