blob: 777f1f8efe0b19ba67f8ec32cd38f4a29ee2da10 [file] [log] [blame]
// 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,
};
}
}