Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial AdminAction infrastructure #6895

Merged
merged 8 commits into from
Aug 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/config/dartlang-pub-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ admins:
email: [email protected]
permissions:
- executeTool
- invokeAction
- listUsers
- manageAssignedTags
- managePackageOwnership
Expand All @@ -49,6 +50,7 @@ admins:
email: [email protected]
permissions:
- executeTool
- invokeAction
- listUsers
- manageAssignedTags
- managePackageOwnership
Expand Down
1 change: 1 addition & 0 deletions app/config/dartlang-pub.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ admins:
email: [email protected]
permissions:
- executeTool
- invokeAction
- listUsers
- manageAssignedTags
- managePackageOwnership
Expand Down
70 changes: 70 additions & 0 deletions app/lib/admin/actions/actions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_dev/admin/actions/publisher_block.dart';
import 'package:pub_dev/admin/actions/publisher_members_list.dart';
import 'package:pub_dev/admin/actions/tool_execute.dart';
import 'package:pub_dev/admin/actions/tool_list.dart';

import '../../shared/exceptions.dart';
import 'task_bump_priority.dart';

export '../../shared/exceptions.dart';

final class AdminAction {
/// Name of the action is an identifier to be specified when the action is triggered.
final String name;

/// Map from option name to description of the option.
final Map<String, String> options;

/// A one-liner summary of what this action does.
final String summary;

/// A multi-line explanation of what this action does, written in markdown.
///
/// This **must** explain what the action does? What the implications are?
/// What other actions could be useful to use in conjunction.
/// What are reasonable expectations around cache-time outs, etc.
///
/// Do write detailed documentation and include examples.
final String description;

/// Function to be called to invoke the action.
///
/// This function is passed an `arguments` Map where keys match the keys in
/// [options].
/// Returns a JSON response, a failed invocation should throw a
/// [ResponseException].
/// Any other exception will be considered an internal error.
final Future<Map<String, Object?>> Function(
Map<String, String> arguments,
) invoke;

AdminAction({
required this.name,
required this.summary,
required this.description,
this.options = const <String, String>{},
required this.invoke,
}) {
// Check that name works as a command-line argument
if (!RegExp(r'^[a-z][a-z0-9-]{0,128}$').hasMatch(name)) {
throw ArgumentError.value(name, 'name');
}
// Check that the keys for options works as command-line options
if (options.keys
.any((k) => !RegExp(r'^[a-z][a-z0-9-]{0,128}$').hasMatch(k))) {
throw ArgumentError.value(options, 'options');
}
}

static List<AdminAction> actions = [
publisherBlock,
publisherMembersList,
taskBumpPriority,
toolExecute,
toolList,
];
}
61 changes: 61 additions & 0 deletions app/lib/admin/actions/publisher_block.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_dev/account/backend.dart';
import 'package:pub_dev/admin/actions/actions.dart';
import 'package:pub_dev/publisher/backend.dart';
import 'package:pub_dev/publisher/models.dart';
import 'package:pub_dev/shared/datastore.dart';

final publisherBlock = AdminAction(
name: 'publisher-block',
summary: 'Block publisher and block all members',
description: '''
Get information about publisher and list all members.
''',
options: {
'publisher': 'Publisher to be blocked',
},
invoke: (options) async {
final publisherId = options['publisher']!;
InvalidInputException.check(
publisherId.isNotEmpty,
'publisher must be given',
);

final publisher = await publisherBackend.getPublisher(publisherId);
if (publisher == null) {
throw NotFoundException.resource(publisherId);
}
final members = await publisherBackend.listPublisherMembers(publisherId);

for (final m in members) {
await accountBackend.updateBlockedFlag(m.userId, true);
}

final publisherKey = dbService.emptyKey.append(Publisher, id: publisherId);
await withRetryTransaction(dbService, (tx) async {
final p = await tx.lookupValue<Publisher>(publisherKey);
p.markForBlocked();
tx.insert(p);
});

return {
'publisher': publisher.publisherId,
'description': publisher.description,
'website': publisher.websiteUrl,
'contact': publisher.contactEmail,
'created': publisher.created,
'blocked': true,
'members': members
.map((m) => {
'email': m.email,
'role': m.role,
'userId': m.userId,
'blocked': true,
})
.toList(),
};
},
);
45 changes: 45 additions & 0 deletions app/lib/admin/actions/publisher_members_list.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_dev/admin/actions/actions.dart';
import 'package:pub_dev/publisher/backend.dart';

final publisherMembersList = AdminAction(
name: 'publisher-members-list',
summary: 'List all members a publisher',
description: '''
Get information about publisher and list all members.
''',
options: {
'publisher': 'Publisher for which to list members',
},
invoke: (options) async {
final publisherId = options['publisher']!;
InvalidInputException.check(
publisherId.isNotEmpty,
'publisher must be given',
);

final publisher = await publisherBackend.getPublisher(publisherId);
if (publisher == null) {
throw NotFoundException.resource(publisherId);
}
final members = await publisherBackend.listPublisherMembers(publisherId);

return {
'publisher': publisher.publisherId,
'description': publisher.description,
'website': publisher.websiteUrl,
'contact': publisher.contactEmail,
'created': publisher.created,
'members': members
.map((m) => {
'email': m.email,
'role': m.role,
'userId': m.userId,
})
.toList(),
};
},
);
33 changes: 33 additions & 0 deletions app/lib/admin/actions/task_bump_priority.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_dev/admin/actions/actions.dart';
import 'package:pub_dev/task/backend.dart';

final taskBumpPriority = AdminAction(
name: 'task-bump-priority',
summary: 'Increase priority for task scheduling of specific package',
description: '''
This action will lower the `PackageState.pendingAt` property for the given
package. This should cause an analysis task for the package to be scheduled
sooner.

This will always set `pendingAt` to the same value, it will not trigger new
analysis, if none is pending, merely increase the priority. Calling it multiple
times will have no effect, it will always set `pendingAt` to the same value.

This is intended for debugging, or solving one-off issues.
''',
options: {
'package': 'Name of package whose priority should be bumped',
},
invoke: (options) async {
final package = options['package']!;
InvalidInputException.checkPackageName(package);

await taskBackend.adminBumpPriority(package);

return {'message': 'Priority may have been bumped, good luck!'};
},
);
33 changes: 33 additions & 0 deletions app/lib/admin/actions/tool_execute.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_dev/admin/actions/actions.dart';
import 'package:pub_dev/admin/backend.dart';

final toolExecute = AdminAction(
name: 'tool-execute',
summary: 'Execute legacy tool',
description: '''
Execute a legacy tool. Beware that args will be split on comma, hence, it's not
possible to call them with arguments that contain comma.
''',
options: {
'tool': 'name of tool to execute',
'args': 'comma separate list of arguments',
},
invoke: (options) async {
final toolName = options['toolName']!;
final args = options['args']!.split(',');
InvalidInputException.check(toolName.isNotEmpty, 'tool must given');

final tool = availableTools[toolName];
if (tool == null) {
throw NotFoundException.resource(toolName);
}

final message = await tool(args);

return {'message': message};
},
);
25 changes: 25 additions & 0 deletions app/lib/admin/actions/tool_list.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:pub_dev/admin/actions/actions.dart';
import 'package:pub_dev/admin/backend.dart';

final toolList = AdminAction(
name: 'tool-list',
summary: 'List legacy tools',
description: '''
Print a list of legacy tools, these can be invoked with the `tool-execute`
command.
''',
options: {},
invoke: (options) async {
return {
'tools': availableTools.keys
.map((k) => {
'tool': k,
})
.toList(),
};
},
);
49 changes: 47 additions & 2 deletions app/lib/admin/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:_pub_shared/data/admin_api.dart' as api;
import 'package:_pub_shared/data/package_api.dart';
import 'package:_pub_shared/search/tags.dart';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:convert/convert.dart';
import 'package:gcloud/service_scope.dart' as ss;
import 'package:logging/logging.dart';
Expand All @@ -31,7 +32,7 @@ import '../shared/datastore.dart';
import '../shared/email.dart';
import '../shared/exceptions.dart';
import '../tool/utils/dart_sdk_version.dart';
import 'tools/block_publisher_and_all_members.dart';
import 'actions/actions.dart' show AdminAction;
import 'tools/create_publisher.dart';
import 'tools/delete_all_staging.dart';
import 'tools/delete_publisher.dart';
Expand Down Expand Up @@ -71,7 +72,6 @@ final Map<String, Tool> availableTools = {
'package-publisher': executeSetPackagePublisher,
'update-package-versions': executeUpdatePackageVersions,
'recent-uploaders': executeRecentUploaders,
'block-publisher-and-all-members': executeBlockPublisherAndAllMembers,
'publisher-member': executePublisherMember,
'publisher-invite-member': executePublisherInviteMember,
'set-package-blocked': executeSetPackageBlocked,
Expand Down Expand Up @@ -688,4 +688,49 @@ class AdminBackend {
});
return await handleGetPackageUploaders(packageName);
}

Future<api.AdminListActionsResponse> listActions() async {
await requireAuthenticatedAdmin(AdminPermission.invokeAction);

return api.AdminListActionsResponse(
actions: AdminAction.actions
.map(
(action) => api.AdminAction(
name: action.name,
summary: action.summary,
description: action.description,
options: action.options,
),
)
.toList(),
);
}

Future<api.AdminInvokeActionResponse> invokeAction(
String actionName,
Map<String, String> args,
) async {
await requireAuthenticatedAdmin(AdminPermission.invokeAction);

final action = AdminAction.actions.firstWhereOrNull(
(a) => a.name == actionName,
);
if (action == null) {
throw NotFoundException.resource(actionName);
}

// Don't allow unknown arguments
final unknownArgs =
args.keys.toSet().difference(action.options.keys.toSet());
InvalidInputException.check(
unknownArgs.isEmpty,
'Unknown options: ${unknownArgs.join(',')}',
);

final result = await action.invoke({
for (final k in action.options.keys) k: args[k] ?? '',
});

return api.AdminInvokeActionResponse(output: result);
}
}
Loading