![auto-submit[bot]](/assets/img/avatar_default.png)
<!-- start_original_pr_link --> Reverts: flutter/flutter#165749 <!-- end_original_pr_link --> <!-- start_initiating_author --> Initiated by: matanlurey <!-- end_initiating_author --> <!-- start_revert_reason --> Reason for reverting: Still passing command-line arguments from recipes that have no effect but cause the runner to crash. <!-- end_revert_reason --> <!-- start_original_pr_author --> Original PR Author: matanlurey <!-- end_original_pr_author --> <!-- start_reviewers --> Reviewed By: {jtmcdole} <!-- end_reviewers --> <!-- start_revert_body --> This change reverts the following previous change: Partial re-land of https://github.com/flutter/flutter/pull/165628: - Fixes the mistake that the `Cocoon` class did things that well, were not specific to Cocoon. - Renamed to `MetricsResultWriter`, as that is all it does now. --- Closes https://github.com/flutter/flutter/issues/165618. The `devicelab/bin/test_runner.dart upload-metrics` command use to have _two_ responsibilities: - Well, upload test **metrics** (benchmarks) to Skia Perf (it still does that) - Upload test **status** to Cocoon (it did until https://github.com/flutter/flutter/pull/165614) As https://github.com/flutter/flutter/pull/165614 proved, this API predated the current LUCI setup, where Cocoon itself receives task status updates from LUCI, and it turns out this entire time, DeviceLab was making (at best) NOP calls, and at worst, causing crashes and corrupt data (https://github.com/flutter/flutter/issues/165610). <!-- end_revert_body --> Co-authored-by: auto-submit[bot] <flutter-engprod-team@google.com>
266 lines
9.0 KiB
Dart
266 lines
9.0 KiB
Dart
// Copyright 2014 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:convert' show Encoding, json;
|
|
import 'dart:io';
|
|
|
|
import 'package:file/file.dart';
|
|
import 'package:file/local.dart';
|
|
import 'package:http/http.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:meta/meta.dart';
|
|
|
|
import 'task_result.dart';
|
|
import 'utils.dart';
|
|
|
|
typedef ProcessRunSync =
|
|
ProcessResult Function(
|
|
String,
|
|
List<String>, {
|
|
Map<String, String>? environment,
|
|
bool includeParentEnvironment,
|
|
bool runInShell,
|
|
Encoding? stderrEncoding,
|
|
Encoding? stdoutEncoding,
|
|
String? workingDirectory,
|
|
});
|
|
|
|
/// Class for test runner to interact with Flutter's infrastructure service, Cocoon.
|
|
///
|
|
/// Cocoon assigns bots to run these devicelab tasks on real devices.
|
|
/// To retrieve these results, the test runner needs to send results back so the database can be updated.
|
|
class Cocoon {
|
|
Cocoon({
|
|
String? serviceAccountTokenPath,
|
|
@visibleForTesting Client? httpClient,
|
|
@visibleForTesting this.fs = const LocalFileSystem(),
|
|
@visibleForTesting this.processRunSync = Process.runSync,
|
|
@visibleForTesting this.requestRetryLimit = 5,
|
|
@visibleForTesting this.requestTimeoutLimit = 30,
|
|
}) : _httpClient = AuthenticatedCocoonClient(
|
|
serviceAccountTokenPath,
|
|
httpClient: httpClient,
|
|
filesystem: fs,
|
|
);
|
|
|
|
/// Client to make http requests to Cocoon.
|
|
final AuthenticatedCocoonClient _httpClient;
|
|
|
|
final ProcessRunSync processRunSync;
|
|
|
|
/// Url used to send results to.
|
|
static const String baseCocoonApiUrl = 'https://flutter-dashboard.appspot.com/api';
|
|
|
|
/// Threshold to auto retry a failed test.
|
|
static const int retryNumber = 2;
|
|
|
|
/// Underlying [FileSystem] to use.
|
|
final FileSystem fs;
|
|
|
|
static final Logger logger = Logger('CocoonClient');
|
|
|
|
@visibleForTesting
|
|
final int requestRetryLimit;
|
|
|
|
@visibleForTesting
|
|
final int requestTimeoutLimit;
|
|
|
|
String get commitSha => _commitSha ?? _readCommitSha();
|
|
String? _commitSha;
|
|
|
|
/// Parse the local repo for the current running commit.
|
|
String _readCommitSha() {
|
|
final ProcessResult result = processRunSync('git', <String>['rev-parse', 'HEAD']);
|
|
if (result.exitCode != 0) {
|
|
throw CocoonException(result.stderr as String);
|
|
}
|
|
|
|
return _commitSha = result.stdout as String;
|
|
}
|
|
|
|
/// Update test status to Cocoon.
|
|
///
|
|
/// Flutter infrastructure's workflow is:
|
|
/// 1. Run DeviceLab test
|
|
/// 2. Request service account token from luci auth (valid for at least 3 minutes)
|
|
/// 3. Update test status from (1) to Cocoon
|
|
///
|
|
/// The `resultsPath` is not available for all tests. When it doesn't show up, we
|
|
/// need to append `CommitBranch`, `CommitSha`, and `BuilderName`.
|
|
Future<void> sendTaskStatus({
|
|
String? resultsPath,
|
|
bool? isTestFlaky,
|
|
String? gitBranch,
|
|
String? builderName,
|
|
String? testStatus,
|
|
String? builderBucket,
|
|
}) async {
|
|
Map<String, dynamic> resultsJson = <String, dynamic>{};
|
|
if (resultsPath != null) {
|
|
final File resultFile = fs.file(resultsPath);
|
|
resultsJson = json.decode(await resultFile.readAsString()) as Map<String, dynamic>;
|
|
} else {
|
|
resultsJson['CommitBranch'] = gitBranch;
|
|
resultsJson['CommitSha'] = commitSha;
|
|
resultsJson['BuilderName'] = builderName;
|
|
resultsJson['NewStatus'] = testStatus;
|
|
}
|
|
resultsJson['TestFlaky'] = isTestFlaky ?? false;
|
|
if (_shouldUpdateCocoon(resultsJson, builderBucket ?? 'prod')) {
|
|
await retry(
|
|
() async =>
|
|
_sendUpdateTaskRequest(resultsJson).timeout(Duration(seconds: requestTimeoutLimit)),
|
|
retryIf:
|
|
(Exception e) => e is SocketException || e is TimeoutException || e is ClientException,
|
|
maxAttempts: requestRetryLimit,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Only post-submit tests on `master` are allowed to update in cocoon.
|
|
bool _shouldUpdateCocoon(Map<String, dynamic> resultJson, String builderBucket) {
|
|
const List<String> supportedBranches = <String>['master'];
|
|
return supportedBranches.contains(resultJson['CommitBranch']) && builderBucket == 'prod';
|
|
}
|
|
|
|
/// Write the given parameters into an update task request and store the JSON in [resultsPath].
|
|
Future<void> writeTaskResultToFile({
|
|
String? builderName,
|
|
String? gitBranch,
|
|
required TaskResult result,
|
|
required String resultsPath,
|
|
}) async {
|
|
final Map<String, dynamic> updateRequest = _constructUpdateRequest(
|
|
gitBranch: gitBranch,
|
|
builderName: builderName,
|
|
result: result,
|
|
);
|
|
final File resultFile = fs.file(resultsPath);
|
|
if (resultFile.existsSync()) {
|
|
resultFile.deleteSync();
|
|
}
|
|
logger.fine('Writing results: ${json.encode(updateRequest)}');
|
|
resultFile.createSync();
|
|
resultFile.writeAsStringSync(json.encode(updateRequest));
|
|
}
|
|
|
|
Map<String, dynamic> _constructUpdateRequest({
|
|
String? builderName,
|
|
required TaskResult result,
|
|
String? gitBranch,
|
|
}) {
|
|
final Map<String, dynamic> updateRequest = <String, dynamic>{
|
|
'CommitBranch': gitBranch,
|
|
'CommitSha': commitSha,
|
|
'BuilderName': builderName,
|
|
'NewStatus': result.succeeded ? 'Succeeded' : 'Failed',
|
|
};
|
|
logger.fine('Update request: $updateRequest');
|
|
|
|
// Make a copy of result data because we may alter it for validation below.
|
|
updateRequest['ResultData'] = result.data;
|
|
|
|
final List<String> validScoreKeys = <String>[];
|
|
if (result.benchmarkScoreKeys != null) {
|
|
for (final String scoreKey in result.benchmarkScoreKeys!) {
|
|
final Object score = result.data![scoreKey] as Object;
|
|
if (score is num) {
|
|
// Convert all metrics to double, which provide plenty of precision
|
|
// without having to add support for multiple numeric types in Cocoon.
|
|
result.data![scoreKey] = score.toDouble();
|
|
validScoreKeys.add(scoreKey);
|
|
}
|
|
}
|
|
}
|
|
updateRequest['BenchmarkScoreKeys'] = validScoreKeys;
|
|
|
|
return updateRequest;
|
|
}
|
|
|
|
Future<void> _sendUpdateTaskRequest(Map<String, dynamic> postBody) async {
|
|
logger.info('Attempting to send update task request to Cocoon.');
|
|
final Map<String, dynamic> response = await _sendCocoonRequest('update-task-status', postBody);
|
|
if (response['Name'] != null) {
|
|
logger.info('Updated Cocoon with results from this task');
|
|
} else {
|
|
logger.info(response);
|
|
logger.severe('Failed to updated Cocoon with results from this task');
|
|
}
|
|
}
|
|
|
|
/// Make an API request to Cocoon.
|
|
Future<Map<String, dynamic>> _sendCocoonRequest(String apiPath, [dynamic jsonData]) async {
|
|
final Uri url = Uri.parse('$baseCocoonApiUrl/$apiPath');
|
|
|
|
/// Retry requests to Cocoon as sometimes there are issues with the servers, such
|
|
/// as version changes to the backend, datastore issues, or latency issues.
|
|
final Response response = await retry(
|
|
() => _httpClient.post(url, body: json.encode(jsonData)),
|
|
retryIf:
|
|
(Exception e) => e is SocketException || e is TimeoutException || e is ClientException,
|
|
maxAttempts: requestRetryLimit,
|
|
);
|
|
return json.decode(response.body) as Map<String, dynamic>;
|
|
}
|
|
}
|
|
|
|
/// [HttpClient] for sending authenticated requests to Cocoon.
|
|
class AuthenticatedCocoonClient extends BaseClient {
|
|
AuthenticatedCocoonClient(
|
|
this._serviceAccountTokenPath, {
|
|
@visibleForTesting Client? httpClient,
|
|
@visibleForTesting FileSystem? filesystem,
|
|
}) : _delegate = httpClient ?? Client(),
|
|
_fs = filesystem ?? const LocalFileSystem();
|
|
|
|
/// Authentication token to have the ability to upload and record test results.
|
|
///
|
|
/// This is intended to only be passed on automated runs on LUCI post-submit.
|
|
final String? _serviceAccountTokenPath;
|
|
|
|
/// Underlying [HttpClient] to send requests to.
|
|
final Client _delegate;
|
|
|
|
/// Underlying [FileSystem] to use.
|
|
final FileSystem _fs;
|
|
|
|
/// Value contained in the service account token file that can be used in http requests.
|
|
String get serviceAccountToken => _serviceAccountToken ?? _readServiceAccountTokenFile();
|
|
String? _serviceAccountToken;
|
|
|
|
/// Get [serviceAccountToken] from the given service account file.
|
|
String _readServiceAccountTokenFile() {
|
|
return _serviceAccountToken = _fs.file(_serviceAccountTokenPath).readAsStringSync().trim();
|
|
}
|
|
|
|
@override
|
|
Future<StreamedResponse> send(BaseRequest request) async {
|
|
request.headers['Service-Account-Token'] = serviceAccountToken;
|
|
final StreamedResponse response = await _delegate.send(request);
|
|
|
|
if (response.statusCode != 200) {
|
|
throw ClientException(
|
|
'AuthenticatedClientError:\n'
|
|
' URI: ${request.url}\n'
|
|
' HTTP Status: ${response.statusCode}\n'
|
|
' Response body:\n'
|
|
'${(await Response.fromStream(response)).body}',
|
|
request.url,
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
}
|
|
|
|
class CocoonException implements Exception {
|
|
CocoonException(this.message);
|
|
|
|
/// The message to show to the issuer to explain the error.
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => 'CocoonException: $message';
|
|
}
|