Add some target protection to `RerunProdTask`. (#4357)
Closes https://github.com/flutter/flutter/issues/165522.
diff --git a/app_dart/lib/src/request_handlers/rerun_prod_task.dart b/app_dart/lib/src/request_handlers/rerun_prod_task.dart
index c80bb62..21bd124 100644
--- a/app_dart/lib/src/request_handlers/rerun_prod_task.dart
+++ b/app_dart/lib/src/request_handlers/rerun_prod_task.dart
@@ -30,7 +30,7 @@
///
/// Expects either [taskKeyParam] or a set of params that give enough detail to lookup a task in datastore.
@immutable
-class RerunProdTask extends ApiRequestHandler<Body> {
+final class RerunProdTask extends ApiRequestHandler<Body> {
const RerunProdTask({
required super.config,
required super.authenticationProvider,
@@ -155,9 +155,29 @@
if (ciYaml.isFusion)
...ciYaml.postsubmitTargets(type: CiType.fusionEngine),
];
- final target = targets.singleWhere(
- (Target target) => target.value.name == task.name,
- );
+
+ // Fail "nicely" on missing a cooresponding target.
+ final Target target;
+ {
+ final matched = targets.where((target) => target.value.name == task.name);
+
+ // Could happen (https://github.com/flutter/flutter/issues/165522).
+ if (matched.isEmpty) {
+ log.warn(
+ 'No matching target ("${task.name}") found in ${targets.map((t) => t.value.name).toList()}.',
+ );
+ return;
+ }
+
+ // Can't happen, as it would be an invalid .ci.yaml.
+ if (matched.length > 1) {
+ throw StateError(
+ 'More than one target ("${task.name}") matched in ${targets.map((t) => t.value.name).toList()}.',
+ );
+ }
+
+ target = matched.first;
+ }
// Prepares Firestore task.
final documentName = FirestoreTaskDocumentName(
diff --git a/app_dart/test/request_handlers/rerun_prod_task_test.dart b/app_dart/test/request_handlers/rerun_prod_task_test.dart
index b12a5fb..7636531 100644
--- a/app_dart/test/request_handlers/rerun_prod_task_test.dart
+++ b/app_dart/test/request_handlers/rerun_prod_task_test.dart
@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'package:cocoon_common_test/cocoon_common_test.dart';
+import 'package:cocoon_server/logging.dart';
import 'package:cocoon_server_test/test_logging.dart';
import 'package:cocoon_service/cocoon_service.dart';
import 'package:cocoon_service/src/model/appengine/commit.dart';
@@ -188,6 +190,82 @@
);
});
+ test('No matching target fails gracefully', () async {
+ final task = generateTask(
+ 2,
+ // This task is in datastore, but not in .ci.yaml.
+ name: 'Windows C',
+ parent: commit,
+ status: Task.statusSucceeded,
+ );
+ config.db.values[task.key] = task;
+ config.db.values[commit.key] = commit;
+ tester.requestData = {...tester.requestData, 'task': 'Windows C'};
+ expect(await tester.post(handler), Body.empty);
+ verifyNever(
+ mockLuciBuildService.checkRerunBuilder(
+ commit: anyNamed('commit'),
+ datastore: anyNamed('datastore'),
+ task: anyNamed('task'),
+ target: anyNamed('target'),
+ tags: anyNamed('tags'),
+ firestoreService: anyNamed('firestoreService'),
+ taskDocument: anyNamed('taskDocument'),
+ ignoreChecks: false,
+ ),
+ );
+
+ expect(
+ log,
+ bufferedLoggerOf(
+ contains(
+ logThat(
+ severity: atLeastWarning,
+ message: contains('No matching target'),
+ ),
+ ),
+ ),
+ );
+ });
+
+ test('Too many matching targets fails forcefully', () async {
+ ciYamlFetcher.ciYaml = exampleNaughtyConfig;
+ final task = generateTask(
+ 2,
+ name: 'Windows A',
+ parent: commit,
+ status: Task.statusSucceeded,
+ );
+
+ config.db.values[task.key] = task;
+ config.db.values[commit.key] = commit;
+ tester.requestData = {...tester.requestData, 'task': 'Windows A'};
+ await expectLater(
+ tester.post(handler),
+ throwsA(
+ isA<StateError>().having(
+ (b) => b.message,
+ 'message',
+ contains(
+ 'More than one target ("Windows A") matched in [Windows A, Windows A]',
+ ),
+ ),
+ ),
+ );
+ verifyNever(
+ mockLuciBuildService.checkRerunBuilder(
+ commit: anyNamed('commit'),
+ datastore: anyNamed('datastore'),
+ task: anyNamed('task'),
+ target: anyNamed('target'),
+ tags: anyNamed('tags'),
+ firestoreService: anyNamed('firestoreService'),
+ taskDocument: anyNamed('taskDocument'),
+ ignoreChecks: false,
+ ),
+ );
+ });
+
test('Re-schedule without any parameters raises exception', () async {
tester.requestData = <String, dynamic>{};
expect(() => tester.post(handler), throwsA(isA<BadRequestException>()));
diff --git a/app_dart/test/src/service/fake_scheduler.dart b/app_dart/test/src/service/fake_scheduler.dart
index 470b4e5..21e815c 100644
--- a/app_dart/test/src/service/fake_scheduler.dart
+++ b/app_dart/test/src/service/fake_scheduler.dart
@@ -133,6 +133,20 @@
},
);
+final exampleNaughtyConfig = CiYamlSet(
+ slug: Config.flutterSlug,
+ branch: Config.defaultBranch(Config.flutterSlug),
+ yamls: {
+ CiType.any: pb.SchedulerConfig(
+ enabledBranches: <String>[Config.defaultBranch(Config.flutterSlug)],
+ targets: <pb.Target>[
+ pb.Target(name: 'Windows A', scheduler: pb.SchedulerSystem.luci),
+ pb.Target(name: 'Windows A', scheduler: pb.SchedulerSystem.luci),
+ ],
+ ),
+ },
+);
+
final exampleFlakyConfig = CiYamlSet(
slug: Config.flutterSlug,
branch: Config.defaultBranch(Config.flutterSlug),