| // 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:async'; |
| |
| import 'package:appengine/appengine.dart'; |
| import 'package:cocoon_service/src/service/access_token_provider.dart'; |
| import 'package:cocoon_service/src/service/reservation_provider.dart'; |
| import 'package:cocoon_service/src/service/task_provider.dart'; |
| import 'package:gcloud/db.dart'; |
| import 'package:googleapis_auth/auth_io.dart'; |
| import 'package:meta/meta.dart'; |
| |
| import '../datastore/cocoon_config.dart'; |
| import '../model/appengine/agent.dart'; |
| import '../model/appengine/commit.dart'; |
| import '../model/appengine/key_helper.dart'; |
| import '../model/appengine/task.dart'; |
| import '../request_handling/api_request_handler.dart'; |
| import '../request_handling/authentication.dart'; |
| import '../request_handling/body.dart'; |
| import '../request_handling/exceptions.dart'; |
| import '../service/datastore.dart'; |
| |
| /// Reserves a pending task so that an agent may run the task. |
| @immutable |
| class ReserveTask extends ApiRequestHandler<ReserveTaskResponse> { |
| const ReserveTask( |
| Config config, |
| AuthenticationProvider authenticationProvider, { |
| @visibleForTesting TaskServiceProvider taskServiceProvider, |
| @visibleForTesting ReservationServiceProvider reservationServiceProvider, |
| @visibleForTesting AccessTokenServiceProvider accessTokenServiceProvider, |
| }) : taskServiceProvider = |
| taskServiceProvider ?? TaskService.defaultProvider, |
| reservationServiceProvider = |
| reservationServiceProvider ?? ReservationService.defaultProvider, |
| accessTokenServiceProvider = |
| accessTokenServiceProvider ?? AccessTokenService.defaultProvider, |
| super(config: config, authenticationProvider: authenticationProvider); |
| |
| final TaskServiceProvider taskServiceProvider; |
| final ReservationServiceProvider reservationServiceProvider; |
| final AccessTokenServiceProvider accessTokenServiceProvider; |
| |
| @override |
| Future<ReserveTaskResponse> post() async { |
| final DatastoreService datastore = |
| DatastoreService.defaultProvider(config.db); |
| final TaskService taskService = taskServiceProvider(datastore); |
| final ReservationService reservationService = |
| reservationServiceProvider(datastore); |
| final AccessTokenService accessTokenService = |
| accessTokenServiceProvider(config); |
| final Map<String, dynamic> params = requestData; |
| Agent agent = authContext.agent; |
| if (agent != null) { |
| if (agent.agentId != params['AgentID']) { |
| throw BadRequestException( |
| 'Authenticated agent (${agent.agentId}) does not match agent ' |
| 'supplied in the request (${params['AgentID']})', |
| ); |
| } |
| } else { |
| final String agentId = params['AgentID'] as String; |
| if (agentId == null) { |
| throw const BadRequestException('AgentID not specified in request'); |
| } |
| final Key key = config.db.emptyKey.append(Agent, id: agentId); |
| agent = await config.db.lookupValue<Agent>(key, orElse: () { |
| throw BadRequestException('Invalid agent ID: $agentId'); |
| }); |
| } |
| |
| const int maxAttempts = 3; |
| for (int attempt = 0; attempt < maxAttempts; attempt++) { |
| final FullTask task = await taskService.findNextTask(agent); |
| |
| if (task == null) { |
| return const ReserveTaskResponse.empty(); |
| } |
| |
| try { |
| await reservationService.secureReservation( |
| task.task, agent.id as String); |
| final ClientContext clientContext = authContext.clientContext; |
| final AccessToken token = await accessTokenService.createAccessToken( |
| scopes: const <String>[ |
| 'https://www.googleapis.com/auth/devstorage.read_write' |
| ], |
| ); |
| final KeyHelper keyHelper = |
| KeyHelper(applicationContext: clientContext.applicationContext); |
| return ReserveTaskResponse(task.task, task.commit, token, keyHelper); |
| } on ReservationLostException { |
| // Keep looking for another task. |
| log.debug( |
| 'Reservation lost for task ${task.task.name} on commit ${task.commit.sha}'); |
| continue; |
| } |
| } |
| |
| log.warning('Could not secure reservation after $maxAttempts; giving up'); |
| return const ReserveTaskResponse.empty(); |
| } |
| } |
| |
| @immutable |
| class ReserveTaskResponse extends JsonBody { |
| const ReserveTaskResponse( |
| this.task, this.commit, this.accessToken, this.keyHelper) |
| : assert(task != null), |
| assert(commit != null), |
| assert(accessToken != null), |
| assert(keyHelper != null); |
| |
| const ReserveTaskResponse.empty() |
| : task = null, |
| commit = null, |
| accessToken = null, |
| keyHelper = null; |
| |
| /// The task that was reserved. |
| /// |
| /// The existence of this task in this response does not mean the task |
| /// reservation has been secured in the cloud datastore yet. It is up to |
| /// callers to manage the consistency of the reservation with the cloud |
| /// datastore. |
| /// |
| /// This may be null, which indicates that no task was available to be |
| /// reserved. |
| /// |
| /// See also: |
| /// |
| /// * [ReserveTask.secureReservation], which secures consistency of the |
| /// reservation with the cloud datastore. |
| final Task task; |
| |
| /// The commit that triggered the creation of [task]. |
| /// |
| /// This commit "owns" [task] and represents the instantiation of the commit |
| /// referenced by [Task.commitKey]. |
| /// |
| /// This may be null, which indicates that no task was available. |
| final Commit commit; |
| |
| /// The OAuth 2.0 access token that the receiver of this response may use to |
| /// make authenticated requests back to App Engine. |
| /// |
| /// This may be null. Generally, when [task] is non-null, callers will want |
| /// to return a response that contains an access token. |
| /// |
| /// See also: |
| /// |
| /// * [withAccessToken], which is used to add an access token to a response |
| /// that otherwise did not have an access token. |
| final AccessToken accessToken; |
| |
| /// Used to serialize keys in the response. |
| final KeyHelper keyHelper; |
| |
| @override |
| Map<String, dynamic> toJson() { |
| // package:json_serializable would work here, but only if we adjust the |
| // agent to match what's output here. Since the agent needs to work against |
| // the Go backend as well (temporarily), we hand-code the JSON format here. |
| final Map<String, dynamic> taskMap = task == null |
| ? null |
| : <String, dynamic>{ |
| 'Task': <String, dynamic>{ |
| 'TimeoutInMinutes': task.timeoutInMinutes, |
| 'Name': task.name, |
| }, |
| 'Key': keyHelper.encode(task.key), |
| }; |
| final Map<String, dynamic> commitMap = commit == null |
| ? null |
| : <String, dynamic>{ |
| 'Checklist': <String, dynamic>{ |
| 'Commit': <String, dynamic>{ |
| 'Sha': commit.sha, |
| }, |
| }, |
| }; |
| return <String, dynamic>{ |
| 'TaskEntity': taskMap, |
| 'ChecklistEntity': commitMap, |
| 'CloudAuthToken': accessToken?.data, |
| }; |
| } |
| } |