| // 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 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, |
| }) : _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'; |
| |
| /// Underlying [FileSystem] to use. |
| final FileSystem fs; |
| |
| static final Logger logger = Logger('CocoonClient'); |
| |
| @visibleForTesting |
| final int requestRetryLimit; |
| |
| 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; |
| } |
| |
| /// Upload the JSON results in [resultsPath] to Cocoon. |
| /// |
| /// Flutter infrastructure's workflow is: |
| /// 1. Run DeviceLab test, writing results to a known path |
| /// 2. Request service account token from luci auth (valid for at least 3 minutes) |
| /// 3. Upload results from (1) to Cocooon |
| Future<void> sendResultsPath(String resultsPath) async { |
| final File resultFile = fs.file(resultsPath); |
| final Map<String, dynamic> resultsJson = json.decode(await resultFile.readAsString()) as Map<String, dynamic>; |
| await _sendUpdateTaskRequest(resultsJson); |
| } |
| |
| /// Send [TaskResult] to Cocoon. |
| // TODO(chillers): Remove when sendResultsPath is used in prod. https://github.com/flutter/flutter/issues/72457 |
| Future<void> sendTaskResult({ |
| @required String builderName, |
| @required TaskResult result, |
| @required String gitBranch, |
| }) async { |
| assert(builderName != null); |
| assert(gitBranch != null); |
| assert(result != null); |
| |
| // Skip logging on test runs |
| Logger.root.level = Level.ALL; |
| Logger.root.onRecord.listen((LogRecord rec) { |
| print('${rec.level.name}: ${rec.time}: ${rec.message}'); |
| }); |
| |
| final Map<String, dynamic> updateRequest = _constructUpdateRequest( |
| gitBranch: gitBranch, |
| builderName: builderName, |
| result: result, |
| ); |
| await _sendUpdateTaskRequest(updateRequest); |
| } |
| |
| /// Write the given parameters into an update task request and store the JSON in [resultsPath]. |
| Future<void> writeTaskResultToFile({ |
| @required String builderName, |
| @required String gitBranch, |
| @required TaskResult result, |
| @required String resultsPath, |
| }) async { |
| assert(builderName != null); |
| assert(gitBranch != null); |
| assert(result != null); |
| assert(resultsPath != null); |
| |
| 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({ |
| @required String builderName, |
| @required TaskResult result, |
| @required 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]; |
| 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 { |
| 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'; |
| } |