| // Copyright 2019 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:convert'; |
| import 'dart:io'; |
| |
| import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; |
| import 'package:http/http.dart' as http; |
| import 'package:fixnum/fixnum.dart'; |
| |
| import 'package:cocoon_service/protos.dart'; |
| |
| import '../logic/qualified_task.dart'; |
| import 'cocoon.dart'; |
| import 'downloader.dart'; |
| |
| /// CocoonService for interacting with flutter/flutter production build data. |
| /// |
| /// This queries API endpoints that are hosted on AppEngine. |
| class AppEngineCocoonService implements CocoonService { |
| /// Creates a new [AppEngineCocoonService]. |
| /// |
| /// If a [client] is not specified, a new [http.Client] instance is created. |
| AppEngineCocoonService({http.Client client, Downloader downloader}) |
| : _client = client ?? http.Client(), |
| _downloader = downloader ?? Downloader(); |
| |
| /// Branch on flutter/flutter to default requests for. |
| final String _defaultBranch = 'master'; |
| |
| /// The Cocoon API endpoint to query |
| /// |
| /// This is the base for all API requests to cocoon |
| static const String _baseApiUrl = 'flutter-dashboard.appspot.com'; |
| |
| final http.Client _client; |
| |
| final Downloader _downloader; |
| |
| @override |
| Future<CocoonResponse<List<CommitStatus>>> fetchCommitStatuses({ |
| CommitStatus lastCommitStatus, |
| String branch, |
| }) async { |
| final Map<String, String> queryParameters = <String, String>{ |
| if (lastCommitStatus != null) 'lastCommitKey': lastCommitStatus.commit.key.child.name, |
| 'branch': branch ?? _defaultBranch, |
| }; |
| final String getStatusUrl = apiEndpoint('/api/public/get-status', queryParameters: queryParameters); |
| |
| /// This endpoint returns JSON [List<Agent>, List<CommitStatus>] |
| final http.Response response = await _client.get(getStatusUrl); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| return CocoonResponse<List<CommitStatus>>.error('/api/public/get-status returned ${response.statusCode}'); |
| } |
| |
| try { |
| final Map<String, Object> jsonResponse = jsonDecode(response.body); |
| return CocoonResponse<List<CommitStatus>>.data(_commitStatusesFromJson(jsonResponse['Statuses'])); |
| } catch (error) { |
| return CocoonResponse<List<CommitStatus>>.error(error.toString()); |
| } |
| } |
| |
| @override |
| Future<CocoonResponse<bool>> fetchTreeBuildStatus({ |
| String branch, |
| }) async { |
| final Map<String, String> queryParameters = <String, String>{ |
| 'branch': branch ?? _defaultBranch, |
| }; |
| final String getBuildStatusUrl = apiEndpoint('/api/public/build-status', queryParameters: queryParameters); |
| |
| /// This endpoint returns JSON {AnticipatedBuildStatus: [BuildStatus]} |
| final http.Response response = await _client.get(getBuildStatusUrl); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| return CocoonResponse<bool>.error('/api/public/build-status returned ${response.statusCode}'); |
| } |
| |
| Map<String, Object> jsonResponse; |
| try { |
| jsonResponse = jsonDecode(response.body); |
| } catch (error) { |
| return const CocoonResponse<bool>.error('/api/public/build-status had a malformed response'); |
| } |
| |
| if (!_isBuildStatusResponseValid(jsonResponse)) { |
| return const CocoonResponse<bool>.error('/api/public/build-status had a malformed response'); |
| } |
| |
| return CocoonResponse<bool>.data(jsonResponse['AnticipatedBuildStatus'] == 'Succeeded'); |
| } |
| |
| @override |
| Future<CocoonResponse<List<Agent>>> fetchAgentStatuses() async { |
| final String getStatusUrl = apiEndpoint('/api/public/get-status'); |
| |
| /// This endpoint returns JSON [List<Agent>, List<CommitStatus>] |
| final http.Response response = await _client.get(getStatusUrl); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| return CocoonResponse<List<Agent>>.error('/api/public/get-status returned ${response.statusCode}'); |
| } |
| |
| try { |
| final Map<String, Object> jsonResponse = jsonDecode(response.body); |
| return CocoonResponse<List<Agent>>.data(_agentStatusesFromJson(jsonResponse['AgentStatuses'])); |
| } catch (error) { |
| return CocoonResponse<List<Agent>>.error(error.toString()); |
| } |
| } |
| |
| @override |
| Future<CocoonResponse<List<String>>> fetchFlutterBranches() async { |
| final String getBranchesUrl = apiEndpoint('/api/public/get-branches'); |
| |
| /// This endpoint returns JSON {"Branches": List<String>} |
| final http.Response response = await _client.get(getBranchesUrl); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| return CocoonResponse<List<String>>.error('/api/public/get-branches returned ${response.statusCode}'); |
| } |
| |
| try { |
| final Map<String, dynamic> jsonResponse = jsonDecode(response.body); |
| final List<String> branches = jsonResponse['Branches'].cast<String>(); |
| return CocoonResponse<List<String>>.data(branches); |
| } catch (error) { |
| return CocoonResponse<List<String>>.error(error.toString()); |
| } |
| } |
| |
| @override |
| Future<bool> rerunTask(Task task, String idToken) async { |
| assert(idToken != null); |
| final String postResetTaskUrl = apiEndpoint('/api/reset-devicelab-task'); |
| |
| /// This endpoint only returns a status code. |
| final http.Response response = await _client.post(postResetTaskUrl, |
| headers: <String, String>{ |
| 'X-Flutter-IdToken': idToken, |
| }, |
| body: jsonEncode(<String, String>{ |
| 'Key': task.key.child.name, |
| })); |
| |
| return response.statusCode == HttpStatus.ok; |
| } |
| |
| /// Downloads the log for [task] to the local storage of the current device. |
| /// Returns true if write was successful, and false if there was a failure. |
| /// |
| /// Only works on the web platform. |
| @override |
| Future<bool> downloadLog(Task task, String idToken, String commitSha) async { |
| assert(task != null); |
| assert(idToken != null); |
| |
| final Map<String, String> queryParameters = <String, String>{'ownerKey': task.key.child.name}; |
| final String getTaskLogUrl = apiEndpoint('/api/get-log', queryParameters: queryParameters); |
| |
| // Only show the first 7 characters of the commit sha. This amount is unique |
| // enough to allow lookup of a commit. |
| final String shortSha = commitSha.substring(0, 7); |
| |
| final String fileName = '${task.name}_${shortSha}_${task.attempts}.log'; |
| |
| return _downloader.download(getTaskLogUrl, fileName, idToken: idToken); |
| } |
| |
| @override |
| Future<CocoonResponse<String>> createAgent(String agentId, List<String> capabilities, String idToken) async { |
| assert(agentId != null); |
| assert(capabilities.isNotEmpty); |
| assert(idToken != null); |
| |
| final String createAgentUrl = apiEndpoint('/api/create-agent'); |
| |
| /// This endpoint returns JSON {'Token': [Token]} |
| final http.Response response = await _client.post( |
| createAgentUrl, |
| headers: <String, String>{'X-Flutter-IdToken': idToken}, |
| body: jsonEncode(<String, Object>{ |
| 'AgentID': agentId, |
| 'Capabilities': capabilities, |
| }), |
| ); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| return const CocoonResponse<String>.error('/api/create-agent did not respond with 200'); |
| } |
| |
| Map<String, Object> responseBody; |
| try { |
| responseBody = jsonDecode(response.body); |
| if (responseBody['Token'] == null) { |
| return const CocoonResponse<String>.error('/api/create-agent returned unexpected response'); |
| } |
| } catch (e) { |
| return const CocoonResponse<String>.error('/api/create-agent returned unexpected response'); |
| } |
| |
| return CocoonResponse<String>.data(responseBody['Token']); |
| } |
| |
| @override |
| Future<CocoonResponse<String>> authorizeAgent(Agent agent, String idToken) async { |
| assert(agent != null); |
| assert(idToken != null); |
| |
| final String authorizeAgentUrl = apiEndpoint('/api/authorize-agent'); |
| |
| /// This endpoint returns JSON {'Token': [Token]} |
| final http.Response response = await _client.post( |
| authorizeAgentUrl, |
| headers: <String, String>{'X-Flutter-IdToken': idToken}, |
| body: jsonEncode(<String, Object>{ |
| 'AgentID': agent.agentId, |
| }), |
| ); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| return const CocoonResponse<String>.error('/api/authorize-agent did not respond with 200'); |
| } |
| |
| Map<String, Object> responseBody; |
| try { |
| responseBody = jsonDecode(response.body); |
| if (responseBody['Token'] == null) { |
| return const CocoonResponse<String>.error('/api/authorize-agent returned unexpected response'); |
| } |
| } catch (e) { |
| return const CocoonResponse<String>.error('/api/authorize-agent returned unexpected response'); |
| } |
| |
| return CocoonResponse<String>.data(responseBody['Token']); |
| } |
| |
| @override |
| Future<void> reserveTask(Agent agent, String idToken) async { |
| assert(agent != null); |
| assert(idToken != null); |
| |
| final String reserveTaskUrl = apiEndpoint('/api/reserve-task'); |
| |
| final http.Response response = await _client.post( |
| reserveTaskUrl, |
| headers: <String, String>{'X-Flutter-IdToken': idToken}, |
| body: jsonEncode(<String, Object>{ |
| 'AgentID': agent.agentId, |
| }), |
| ); |
| |
| if (response.statusCode != HttpStatus.ok) { |
| throw Exception('/api/reserve-task did not respond with 200'); |
| } |
| |
| Map<String, Object> responseBody; |
| try { |
| responseBody = jsonDecode(response.body); |
| if (responseBody['Task'] == null) { |
| throw Exception('/api/reserve-task returned unexpected response'); |
| } |
| } catch (e) { |
| throw Exception('/api/reserve-task returned unexpected response'); |
| } |
| } |
| |
| /// Construct the API endpoint based on the priority of using a local endpoint |
| /// before falling back to the production endpoint. |
| /// |
| /// This functions resolves the relative url endpoint to the production endpoint |
| /// that can be used on web to the production endpoint if running not on web. |
| /// This is because only on web a Cocoon backend can be running from the same |
| /// host as this Flutter application, but on mobile we need to ping a separate |
| /// production endpoint. |
| /// |
| /// The urlSuffix begins with a slash, e.g. "/api/public/get-status". |
| /// |
| /// [queryParameters] are appended to the url and are url encoded. |
| @visibleForTesting |
| String apiEndpoint( |
| String urlSuffix, { |
| Map<String, String> queryParameters, |
| }) { |
| final Uri uri = Uri.https(_baseApiUrl, urlSuffix, queryParameters); |
| final String url = uri.toString(); |
| |
| return kIsWeb ? url.replaceAll('https://$_baseApiUrl', '') : url; |
| } |
| |
| /// Check if [Map<String,Object>] follows the format for build-status. |
| /// |
| /// Template of the response it should receive: |
| /// ```json |
| /// { |
| /// "AnticipatedBuildStatus": "Succeeded"|"Failed" |
| /// } |
| /// ``` |
| bool _isBuildStatusResponseValid(Map<String, Object> response) { |
| if (!response.containsKey('AnticipatedBuildStatus')) { |
| return false; |
| } |
| |
| final String treeBuildStatus = response['AnticipatedBuildStatus']; |
| if (treeBuildStatus != 'Failed' && treeBuildStatus != 'Succeeded') { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| List<Agent> _agentStatusesFromJson(List<Object> jsonAgentStatuses) { |
| final List<Agent> agents = <Agent>[]; |
| |
| for (final Map<String, Object> jsonAgent in jsonAgentStatuses) { |
| final List<Object> objectCapabilities = jsonAgent['Capabilities']; |
| final List<String> capabilities = objectCapabilities.map((Object value) => value.toString()).toList(); |
| final Agent agent = Agent() |
| ..agentId = jsonAgent['AgentID'] |
| ..healthCheckTimestamp = Int64.parseInt(jsonAgent['HealthCheckTimestamp'].toString()) |
| ..isHealthy = jsonAgent['IsHealthy'] |
| ..capabilities.addAll(capabilities) |
| ..healthDetails = jsonAgent['HealthDetails']; |
| agents.add(agent); |
| } |
| |
| return agents; |
| } |
| |
| List<CommitStatus> _commitStatusesFromJson(List<Object> jsonCommitStatuses) { |
| assert(jsonCommitStatuses != null); |
| // TODO(chillers): Remove adapter code to just use proto fromJson method. https://github.com/flutter/cocoon/issues/441 |
| |
| final List<CommitStatus> statuses = <CommitStatus>[]; |
| |
| for (final Map<String, Object> jsonCommitStatus in jsonCommitStatuses) { |
| final Map<String, Object> checklist = jsonCommitStatus['Checklist']; |
| statuses.add(CommitStatus() |
| ..commit = _commitFromJson(checklist) |
| ..branch = _branchFromJson(checklist) |
| ..stages.addAll(_stagesFromJson(jsonCommitStatus['Stages']))); |
| } |
| |
| return statuses; |
| } |
| |
| String _branchFromJson(Map<String, Object> jsonChecklist) { |
| assert(jsonChecklist != null); |
| |
| final Map<String, Object> checklist = jsonChecklist['Checklist']; |
| return checklist['Branch']; |
| } |
| |
| Commit _commitFromJson(Map<String, Object> jsonChecklist) { |
| assert(jsonChecklist != null); |
| |
| final Map<String, Object> checklist = jsonChecklist['Checklist']; |
| |
| final Map<String, Object> commit = checklist['Commit']; |
| final Map<String, Object> author = commit['Author']; |
| |
| return Commit() |
| ..key = (RootKey()..child = (Key()..name = jsonChecklist['Key'])) |
| ..timestamp = Int64() + checklist['CreateTimestamp'] |
| ..sha = commit['Sha'] |
| ..author = author['Login'] |
| ..authorAvatarUrl = author['avatar_url'] |
| ..repository = checklist['FlutterRepositoryPath'] |
| ..branch = checklist['Branch']; |
| } |
| |
| List<Stage> _stagesFromJson(List<Object> json) { |
| assert(json != null); |
| final List<Stage> stages = <Stage>[]; |
| |
| for (final Object jsonStage in json) { |
| stages.add(_stageFromJson(jsonStage)); |
| } |
| |
| return stages; |
| } |
| |
| Stage _stageFromJson(Map<String, Object> json) { |
| assert(json != null); |
| return Stage() |
| ..name = json['Name'] |
| ..tasks.addAll(_tasksFromJson(json['Tasks'])) |
| ..taskStatus = json['Status']; |
| } |
| |
| List<Task> _tasksFromJson(List<Object> json) { |
| assert(json != null); |
| final List<Task> tasks = <Task>[]; |
| |
| for (final Map<String, Object> jsonTask in json) { |
| tasks.add(_taskFromJson(jsonTask)); |
| } |
| |
| return tasks; |
| } |
| |
| Task _taskFromJson(Map<String, Object> json) { |
| assert(json != null); |
| |
| final Map<String, Object> taskData = json['Task']; |
| final List<Object> objectRequiredCapabilities = taskData['RequiredCapabilities']; |
| |
| final Task task = Task() |
| ..key = (RootKey()..child = (Key()..name = json['Key'])) |
| ..createTimestamp = Int64(taskData['CreateTimestamp']) |
| ..startTimestamp = Int64(taskData['StartTimestamp']) |
| ..endTimestamp = Int64(taskData['EndTimestamp']) |
| ..name = taskData['Name'] |
| ..attempts = taskData['Attempts'] |
| ..isFlaky = taskData['Flaky'] |
| ..timeoutInMinutes = taskData['TimeoutInMinutes'] |
| ..reason = taskData['Reason'] |
| ..requiredCapabilities.add(objectRequiredCapabilities.toString()) |
| ..reservedForAgentId = taskData['ReservedForAgentID'] |
| ..stageName = taskData['StageName'] |
| ..status = taskData['Status']; |
| |
| if (taskData['StageName'] == StageName.luci) { |
| task |
| ..buildNumberList = taskData['BuildNumberList'] ?? '' |
| ..builderName = taskData['BuilderName'] ?? '' |
| ..luciBucket = taskData['LuciBucket'] ?? ''; |
| } |
| return task; |
| } |
| } |