blob: 54b46168bc8cc102af589631351de8c92e685a9f [file] [log] [blame]
// 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) : assert(message != null);
/// The message to show to the issuer to explain the error.
final String message;
@override
String toString() => 'CocoonException: $message';
}