Merge changes Ideb18201,I0ac9d4de
* changes: Add REST API quota checks Add QuotaBackend and QuotaEnforcer extension point
This commit is contained in:
@@ -2670,6 +2670,94 @@ to change in order to be compliant. These requirements should be kept once they
|
||||
are met, but marked as `OK`. If the requirements were not displayed, reviewers
|
||||
would need to use their precious time to manually check that they were met.
|
||||
|
||||
[[quota-enforcer]]
|
||||
== Quota Enforcer
|
||||
|
||||
Gerrit provides an extension point that allows a plugin to enforce quota.
|
||||
link:quota.html[This documentation page] has a list of all quota requests that
|
||||
Gerrit core issues. Plugins can choose to respond to all or just a subset of
|
||||
requests. Some implementations might want to keep track of user quota in buckets,
|
||||
others might just check against instance or project state to enforce limits on how
|
||||
many projects can be created or how large a repository can become.
|
||||
|
||||
Checking against instance state can be racy for concurrent requests as the server does not
|
||||
refill tokens if the action fails in a later stage (e.g. database failure). If
|
||||
plugins want to guarantee an absolute maximum on a resource, they have to do their own
|
||||
book-keeping.
|
||||
|
||||
[source, java]
|
||||
----
|
||||
import com.google.server.quota.QuotaEnforcer;
|
||||
|
||||
class ProjectLimiter implements QuotaEnforcer {
|
||||
private final long maxNumberOfProjects = 100;
|
||||
@Override
|
||||
QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
|
||||
if (!"/projects/create".equals(quotaGroup)) {
|
||||
return QuotaResponse.noOp();
|
||||
}
|
||||
// No deduction because we always check against the instance state (racy but fine for
|
||||
// this plugin)
|
||||
if (currentNumberOfProjects() + numTokens > maxNumberOfProjects) {
|
||||
return QuotaResponse.error("too many projects");
|
||||
}
|
||||
return QuotaResponse.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
|
||||
// Since we are not keeping any state in this enforcer, we can simply call requestTokens().
|
||||
return requestTokens(quotaGroup, ctx, numTokens);
|
||||
}
|
||||
|
||||
void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
[source, java]
|
||||
----
|
||||
import com.google.server.quota.QuotaEnforcer;
|
||||
|
||||
class ApiQpsEnforcer implements QuotaEnforcer {
|
||||
// AutoRefillingPerUserBuckets is a imaginary bucket implementation that could be based on
|
||||
// a loading cache or a commonly used bucketing algorithm.
|
||||
private final AutoRefillingPerUserBuckets<CurrentUser, Long> buckets;
|
||||
@Override
|
||||
QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
|
||||
if (!quotaGroup.startsWith("/restapi/")) {
|
||||
return QuotaResponse.noOp();
|
||||
}
|
||||
boolean success = buckets.deduct(ctx.user(), numTokens);
|
||||
if (!success) {
|
||||
return QuotaResponse.error("user sent too many qps, please wait for 5 minutes");
|
||||
}
|
||||
return QuotaResponse.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
|
||||
if (!quotaGroup.startsWith("/restapi/")) {
|
||||
return QuotaResponse.noOp();
|
||||
}
|
||||
boolean success = buckets.checkOnly(ctx.user(), numTokens);
|
||||
if (!success) {
|
||||
return QuotaResponse.error("user sent too many qps, please wait for 5 minutes");
|
||||
}
|
||||
return QuotaResponse.ok();
|
||||
}
|
||||
|
||||
@Override
|
||||
void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
|
||||
if (!quotaGroup.startsWith("/restapi/")) {
|
||||
return;
|
||||
}
|
||||
buckets.add(ctx.user(), numTokens);
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
|
||||
== SEE ALSO
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
.. link:js-api.html[JavaScript Plugin API]
|
||||
.. link:config-validation.html[Validation Interfaces]
|
||||
.. link:dev-stars.html[Starring Changes]
|
||||
.. link:quota.html[Quota Enforcement]
|
||||
. link:dev-design.html[System Design]
|
||||
. link:i18n-readme.html[i18n Support]
|
||||
|
||||
|
||||
50
Documentation/quota.txt
Normal file
50
Documentation/quota.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
= Gerrit Code Review - Quota
|
||||
|
||||
Gerrit does not provide out of the box quota enforcement. However, it does
|
||||
support an extension mechanism for plugins to hook into to provide this
|
||||
functionality. The most prominent plugin is the
|
||||
link:https://gerrit.googlesource.com/plugins/quota/[Quota Plugin].
|
||||
|
||||
This documentation is intended to be read by plugin developers. It contains all
|
||||
quota requests implemented in Gerrit-core as well as the metadata that they have
|
||||
associated.
|
||||
|
||||
== Quota Groups
|
||||
|
||||
The following quota groups are defined in core Gerrit:
|
||||
|
||||
=== REST API
|
||||
[[rest-api]]
|
||||
|
||||
The REST API enforces quota after the resource was parsed (if applicable) and before the
|
||||
endpoint's logic is executed. This enables quota enforcer implementations to throttle calls
|
||||
to specific endpoints while knowing the general context (user and top-level entity such as
|
||||
change, project or account).
|
||||
|
||||
If the quota enforcer wants to throttle HTTP requests, they should use
|
||||
link:quota.html#http-requests[HTTP Requests] instead.
|
||||
|
||||
The quota groups used for checking follow the exact definition of the endoint in the REST
|
||||
API, but remove all IDs. The schema is:
|
||||
|
||||
/restapi/<ENDPOINT>:<HTTP-METHOD>
|
||||
|
||||
Examples:
|
||||
|
||||
[options="header",cols="1,6"]
|
||||
|=======================
|
||||
|HTTP call |Quota Group |Metadata
|
||||
|GET /a/changes/1/revisions/current/detail |/changes/revisions/detail:GET |CurrentUser, Change.Id, Project.NameKey
|
||||
|POST /a/changes/ |/changes/:POST |CurrentUser
|
||||
|GET /a/accounts/self/detail |/accounts/detail:GET |CurrentUser, Account.Id
|
||||
|=======================
|
||||
|
||||
The user provided in the check's metadata is always the calling user (having the
|
||||
impersonation bit and real user set in case the user is impersonating another user).
|
||||
|
||||
GERRIT
|
||||
------
|
||||
Part of link:index.html[Gerrit Code Review]
|
||||
|
||||
SEARCHBOX
|
||||
---------
|
||||
@@ -191,6 +191,11 @@ Preconditions] section.
|
||||
"`422 Unprocessable Entity`" is returned if the ID of a resource that is
|
||||
specified in the request body cannot be resolved.
|
||||
|
||||
==== 429 Too Many Requests
|
||||
"`429 Too Many Requests`" is returned if the request exhausted any set
|
||||
quota limits. Depending on the exhausted quota, the request may be retried
|
||||
with exponential backoff.
|
||||
|
||||
[[tracing]]
|
||||
=== Request Tracing
|
||||
For each REST endpoint tracing can be enabled by setting the
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.httpd.restapi;
|
||||
|
||||
import com.google.gerrit.extensions.restapi.RestResource;
|
||||
import com.google.gerrit.server.account.AccountResource;
|
||||
import com.google.gerrit.server.change.ChangeResource;
|
||||
import com.google.gerrit.server.project.ProjectResource;
|
||||
import com.google.gerrit.server.quota.QuotaBackend;
|
||||
import com.google.gerrit.server.quota.QuotaException;
|
||||
import com.google.gerrit.util.http.RequestUtil;
|
||||
import javax.inject.Inject;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Enforces quota on specific REST API endpoints.
|
||||
*
|
||||
* <p>Examples:
|
||||
*
|
||||
* <ul>
|
||||
* <li>GET /a/accounts/self/detail => /restapi/accounts/detail:GET
|
||||
* <li>GET /changes/123/revisions/current/detail => /restapi/changes/revisions/detail:GET
|
||||
* <li>PUT /changes/10/reviewed => /changes/reviewed:PUT
|
||||
* </ul>
|
||||
*
|
||||
* <p>Adds context (change, project, account) to the quota check if the call is for an existing
|
||||
* entity that was successfully parsed. This quota check is generally enforced after the resource
|
||||
* was parsed, but before the view is executed. If a quota enforcer desires to throttle earlier,
|
||||
* they should consider quota groups in the {@code /http/*} space.
|
||||
*/
|
||||
public class RestApiQuotaEnforcer {
|
||||
private final QuotaBackend quotaBackend;
|
||||
|
||||
@Inject
|
||||
RestApiQuotaEnforcer(QuotaBackend quotaBackend) {
|
||||
this.quotaBackend = quotaBackend;
|
||||
}
|
||||
|
||||
/** Enforce quota on a request not tied to any {@code RestResource}. */
|
||||
void enforce(HttpServletRequest req) throws QuotaException {
|
||||
String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
|
||||
quotaBackend
|
||||
.currentUser()
|
||||
.requestToken(quotaGroup(pathForQuotaReporting, req.getMethod()))
|
||||
.throwOnError();
|
||||
}
|
||||
|
||||
/** Enforce quota on a request for a given resource. */
|
||||
void enforce(RestResource rsrc, HttpServletRequest req) throws QuotaException {
|
||||
String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
|
||||
// Enrich the quota request we are operating on an interesting collection
|
||||
QuotaBackend.WithResource report = quotaBackend.currentUser();
|
||||
if (rsrc instanceof ChangeResource) {
|
||||
ChangeResource changeResource = (ChangeResource) rsrc;
|
||||
report =
|
||||
quotaBackend.currentUser().change(changeResource.getId(), changeResource.getProject());
|
||||
} else if (rsrc instanceof AccountResource) {
|
||||
AccountResource accountResource = (AccountResource) rsrc;
|
||||
report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
|
||||
} else if (rsrc instanceof ProjectResource) {
|
||||
ProjectResource accountResource = (ProjectResource) rsrc;
|
||||
report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
|
||||
}
|
||||
|
||||
report.requestToken(quotaGroup(pathForQuotaReporting, req.getMethod())).throwOnError();
|
||||
}
|
||||
|
||||
private static String quotaGroup(String path, String method) {
|
||||
return "/restapi" + path + ":" + method;
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ import com.google.gerrit.server.logging.TraceContext;
|
||||
import com.google.gerrit.server.permissions.GlobalPermission;
|
||||
import com.google.gerrit.server.permissions.PermissionBackend;
|
||||
import com.google.gerrit.server.permissions.PermissionBackendException;
|
||||
import com.google.gerrit.server.quota.QuotaException;
|
||||
import com.google.gerrit.server.update.UpdateException;
|
||||
import com.google.gerrit.server.util.time.TimeUtil;
|
||||
import com.google.gerrit.util.http.CacheHeaders;
|
||||
@@ -227,6 +228,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
final AuditService auditService;
|
||||
final RestApiMetrics metrics;
|
||||
final Pattern allowOrigin;
|
||||
final RestApiQuotaEnforcer quotaChecker;
|
||||
|
||||
@Inject
|
||||
Globals(
|
||||
@@ -236,6 +238,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
PermissionBackend permissionBackend,
|
||||
AuditService auditService,
|
||||
RestApiMetrics metrics,
|
||||
RestApiQuotaEnforcer quotaChecker,
|
||||
@GerritServerConfig Config cfg) {
|
||||
this.currentUser = currentUser;
|
||||
this.webSession = webSession;
|
||||
@@ -243,6 +246,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
this.permissionBackend = permissionBackend;
|
||||
this.auditService = auditService;
|
||||
this.metrics = metrics;
|
||||
this.quotaChecker = quotaChecker;
|
||||
allowOrigin = makeAllowOrigin(cfg);
|
||||
}
|
||||
|
||||
@@ -317,6 +321,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
viewData = new ViewData(null, null);
|
||||
|
||||
if (path.isEmpty()) {
|
||||
globals.quotaChecker.enforce(req);
|
||||
if (rc instanceof NeedsParams) {
|
||||
((NeedsParams) rc).setParams(qp.params());
|
||||
}
|
||||
@@ -339,6 +344,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
IdString id = path.remove(0);
|
||||
try {
|
||||
rsrc = rc.parse(rsrc, id);
|
||||
globals.quotaChecker.enforce(rsrc, req);
|
||||
if (path.isEmpty()) {
|
||||
checkPreconditions(req);
|
||||
}
|
||||
@@ -346,6 +352,7 @@ public class RestApiServlet extends HttpServlet {
|
||||
if (!path.isEmpty()) {
|
||||
throw e;
|
||||
}
|
||||
globals.quotaChecker.enforce(req);
|
||||
|
||||
if (isPost(req) || isPut(req)) {
|
||||
RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
|
||||
@@ -602,6 +609,9 @@ public class RestApiServlet extends HttpServlet {
|
||||
status = SC_INTERNAL_SERVER_ERROR;
|
||||
responseBytes = handleException(e, req, res);
|
||||
}
|
||||
} catch (QuotaException e) {
|
||||
responseBytes =
|
||||
replyError(req, res, status = 429, messageOr(e, "Quota limit reached"), e.caching(), e);
|
||||
} catch (Exception e) {
|
||||
status = SC_INTERNAL_SERVER_ERROR;
|
||||
responseBytes = handleException(e, req, res);
|
||||
|
||||
@@ -170,6 +170,7 @@ import com.google.gerrit.server.query.change.ChangeData;
|
||||
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
|
||||
import com.google.gerrit.server.query.change.ChangeQueryProcessor;
|
||||
import com.google.gerrit.server.query.change.ConflictsCacheImpl;
|
||||
import com.google.gerrit.server.quota.QuotaEnforcer;
|
||||
import com.google.gerrit.server.restapi.change.SuggestReviewers;
|
||||
import com.google.gerrit.server.restapi.group.GroupModule;
|
||||
import com.google.gerrit.server.rules.DefaultSubmitRule;
|
||||
@@ -394,6 +395,7 @@ public class GerritGlobalModule extends FactoryModule {
|
||||
DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
|
||||
DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
|
||||
DynamicSet.setOf(binder(), SubmitRule.class);
|
||||
DynamicSet.setOf(binder(), QuotaEnforcer.class);
|
||||
|
||||
DynamicMap.mapOf(binder(), MailFilter.class);
|
||||
bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
|
||||
|
||||
149
java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
Normal file
149
java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.server.quota;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkState;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.flogger.FluentLogger;
|
||||
import com.google.gerrit.reviewdb.client.Account;
|
||||
import com.google.gerrit.reviewdb.client.Change;
|
||||
import com.google.gerrit.reviewdb.client.Project.NameKey;
|
||||
import com.google.gerrit.server.CurrentUser;
|
||||
import com.google.gerrit.server.plugincontext.PluginSetContext;
|
||||
import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Provider;
|
||||
import javax.inject.Singleton;
|
||||
|
||||
@Singleton
|
||||
public class DefaultQuotaBackend implements QuotaBackend {
|
||||
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||
|
||||
private final Provider<CurrentUser> userProvider;
|
||||
private final PluginSetContext<QuotaEnforcer> quotaEnforcers;
|
||||
|
||||
@Inject
|
||||
DefaultQuotaBackend(
|
||||
Provider<CurrentUser> userProvider, PluginSetContext<QuotaEnforcer> quotaEnforcers) {
|
||||
this.userProvider = userProvider;
|
||||
this.quotaEnforcers = quotaEnforcers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WithUser currentUser() {
|
||||
return new WithUser(quotaEnforcers, userProvider.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public WithUser user(CurrentUser user) {
|
||||
return new WithUser(quotaEnforcers, user);
|
||||
}
|
||||
|
||||
private static QuotaResponse.Aggregated request(
|
||||
PluginSetContext<QuotaEnforcer> quotaEnforcers,
|
||||
String quotaGroup,
|
||||
QuotaRequestContext requestContext,
|
||||
long numTokens,
|
||||
boolean deduct) {
|
||||
checkState(numTokens > 0, "numTokens must be a positive, non-zero long");
|
||||
|
||||
// PluginSets can change their content when plugins (de-)register. Copy the currently registered
|
||||
// plugins so that we can iterate twice on a stable list.
|
||||
List<PluginSetEntryContext<QuotaEnforcer>> enforcers = ImmutableList.copyOf(quotaEnforcers);
|
||||
List<QuotaResponse> responses = new ArrayList<>(enforcers.size());
|
||||
for (PluginSetEntryContext<QuotaEnforcer> enforcer : enforcers) {
|
||||
try {
|
||||
if (deduct) {
|
||||
responses.add(enforcer.call(p -> p.requestTokens(quotaGroup, requestContext, numTokens)));
|
||||
} else {
|
||||
responses.add(enforcer.call(p -> p.dryRun(quotaGroup, requestContext, numTokens)));
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
logger.atSevere().withCause(e).log("exception while enforcing quota");
|
||||
responses.add(QuotaResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
if (deduct && responses.stream().anyMatch(r -> r.status().isError())) {
|
||||
// Roll back the quota request for all enforcers that deducted the quota (= the request
|
||||
// succeeded). Don't touch failed enforcers as the interface contract said that failed
|
||||
// requests should not be deducted.
|
||||
for (int i = 0; i < responses.size(); i++) {
|
||||
if (responses.get(i).status().isOk()) {
|
||||
enforcers.get(i).run(p -> p.refill(quotaGroup, requestContext, numTokens));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.atFine().log(
|
||||
"Quota request for %s with %s (deduction=%s) for %s token returned %s",
|
||||
quotaGroup,
|
||||
requestContext,
|
||||
deduct ? "(deduction=yes)" : "(deduction=no)",
|
||||
numTokens,
|
||||
responses);
|
||||
return new AutoValue_QuotaResponse_Aggregated(ImmutableList.copyOf(responses));
|
||||
}
|
||||
|
||||
static class WithUser extends WithResource implements QuotaBackend.WithUser {
|
||||
WithUser(PluginSetContext<QuotaEnforcer> quotaEnforcers, CurrentUser user) {
|
||||
super(quotaEnforcers, QuotaRequestContext.builder().user(user).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuotaBackend.WithResource account(Account.Id account) {
|
||||
QuotaRequestContext ctx = requestContext.toBuilder().account(account).build();
|
||||
return new WithResource(quotaEnforcers, ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuotaBackend.WithResource project(NameKey project) {
|
||||
QuotaRequestContext ctx = requestContext.toBuilder().project(project).build();
|
||||
return new WithResource(quotaEnforcers, ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuotaBackend.WithResource change(Change.Id change, NameKey project) {
|
||||
QuotaRequestContext ctx = requestContext.toBuilder().change(change).project(project).build();
|
||||
return new WithResource(quotaEnforcers, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
static class WithResource implements QuotaBackend.WithResource {
|
||||
protected final QuotaRequestContext requestContext;
|
||||
protected final PluginSetContext<QuotaEnforcer> quotaEnforcers;
|
||||
|
||||
private WithResource(
|
||||
PluginSetContext<QuotaEnforcer> quotaEnforcers, QuotaRequestContext quotaRequestContext) {
|
||||
this.quotaEnforcers = quotaEnforcers;
|
||||
this.requestContext = quotaRequestContext;
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuotaResponse.Aggregated requestTokens(String quotaGroup, long numTokens) {
|
||||
return DefaultQuotaBackend.request(
|
||||
quotaEnforcers, quotaGroup, requestContext, numTokens, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public QuotaResponse.Aggregated dryRun(String quotaGroup, long numTokens) {
|
||||
return DefaultQuotaBackend.request(
|
||||
quotaEnforcers, quotaGroup, requestContext, numTokens, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
java/com/google/gerrit/server/quota/QuotaBackend.java
Normal file
86
java/com/google/gerrit/server/quota/QuotaBackend.java
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.server.quota;
|
||||
|
||||
import com.google.gerrit.reviewdb.client.Account;
|
||||
import com.google.gerrit.reviewdb.client.Change;
|
||||
import com.google.gerrit.reviewdb.client.Project;
|
||||
import com.google.gerrit.server.CurrentUser;
|
||||
import com.google.inject.ImplementedBy;
|
||||
|
||||
/**
|
||||
* Backend interface to perform quota requests on. By default, this interface is backed by {@link
|
||||
* DefaultQuotaBackend} which calls all plugins that implement {@link QuotaEnforcer}. A different
|
||||
* implementation might be bound in tests. Plugins are not supposed to implement this interface, but
|
||||
* bind a {@link QuotaEnforcer} implementation instead.
|
||||
*
|
||||
* <p>All quota requests require a quota group and a user. Enriching them with a top-level entity
|
||||
* {@code Change, Project, Account} is optional but should be done if the request is targeted.
|
||||
*
|
||||
* <p>Example usage:
|
||||
*
|
||||
* <pre>
|
||||
* quotaBackend.currentUser().project(projectName).requestToken("/projects/create").throwOnError();
|
||||
* quotaBackend.user(user).requestToken("/restapi/config/put").throwOnError();
|
||||
* QuotaResponse.Aggregated result = quotaBackend.currentUser().account(accountId).requestToken("/restapi/accounts/emails/validate");
|
||||
* QuotaResponse.Aggregated result = quotaBackend.currentUser().project(projectName).requestTokens("/projects/git/upload", numBytesInPush);
|
||||
* </pre>
|
||||
*
|
||||
* <p>All quota groups must be documented in {@code quota.txt} and detail the metadata that is
|
||||
* provided (i.e. the parameters used to scope down the quota request).
|
||||
*/
|
||||
@ImplementedBy(DefaultQuotaBackend.class)
|
||||
public interface QuotaBackend {
|
||||
/** Constructs a request for the current user. */
|
||||
WithUser currentUser();
|
||||
|
||||
/**
|
||||
* See {@link #currentUser()}. Use this method only if you can't guarantee that the request is for
|
||||
* the current user (e.g. impersonation).
|
||||
*/
|
||||
WithUser user(CurrentUser user);
|
||||
|
||||
/**
|
||||
* An interface capable of issuing quota requests. Scope can be futher reduced by providing a
|
||||
* top-level entity.
|
||||
*/
|
||||
interface WithUser extends WithResource {
|
||||
/** Scope the request down to an account. */
|
||||
WithResource account(Account.Id account);
|
||||
|
||||
/** Scope the request down to a project. */
|
||||
WithResource project(Project.NameKey project);
|
||||
|
||||
/** Scope the request down to a change. */
|
||||
WithResource change(Change.Id change, Project.NameKey project);
|
||||
}
|
||||
|
||||
/** An interface capable of issuing quota requests. */
|
||||
interface WithResource {
|
||||
/** Issues a single quota request for {@code 1} token. */
|
||||
default QuotaResponse.Aggregated requestToken(String quotaGroup) {
|
||||
return requestTokens(quotaGroup, 1);
|
||||
}
|
||||
|
||||
/** Issues a single quota request for {@code numTokens} tokens. */
|
||||
QuotaResponse.Aggregated requestTokens(String quotaGroup, long numTokens);
|
||||
|
||||
/**
|
||||
* Issues a single quota request for {@code numTokens} tokens but signals the implementations
|
||||
* not to deduct any quota yet. Can be used to do pre-flight requests where necessary
|
||||
*/
|
||||
QuotaResponse.Aggregated dryRun(String quotaGroup, long tokens);
|
||||
}
|
||||
}
|
||||
72
java/com/google/gerrit/server/quota/QuotaEnforcer.java
Normal file
72
java/com/google/gerrit/server/quota/QuotaEnforcer.java
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.server.quota;
|
||||
|
||||
import com.google.gerrit.extensions.annotations.ExtensionPoint;
|
||||
|
||||
/**
|
||||
* Allows plugins to enforce different types of quota.
|
||||
*
|
||||
* <p>Enforcing quotas can be helpful in many scenarios. For example:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Reducing the number of QPS a user can send to Gerrit on the REST API
|
||||
* <li>Limiting the size of a repository (project)
|
||||
* <li>Limiting the number of changes in a repository
|
||||
* <li>Limiting the number of actions that have the potential for spam, abuse or flooding if not
|
||||
* limited
|
||||
* </ul>
|
||||
*
|
||||
* This endpoint gives plugins the capability to enforce any of these limits. The server will ask
|
||||
* all plugins that registered this endpoint and collect all results. In case {@link
|
||||
* #requestTokens(String, QuotaRequestContext, long)} was called and one or more plugins returned an
|
||||
* erroneous result, the server will call {@link #refill(String, QuotaRequestContext, long)} on all
|
||||
* plugins with the same parameters. Plugins that deducted tokens in the {@link
|
||||
* #requestTokens(String, QuotaRequestContext, long)} call can refill them so that users don't get
|
||||
* charged any quota for failed requests.
|
||||
*
|
||||
* <p>Not all implementations will need to deduct quota on {@link #requestTokens(String,
|
||||
* QuotaRequestContext, long)}}. Implementations that work on top of instance-attributes, such as
|
||||
* the number of projects per instance can choose not to keep any state and always check how many
|
||||
* existing projects there are and if adding the inquired number would exceed the limit. In this
|
||||
* case, {@link #requestTokens(String, QuotaRequestContext, long)} and {@link #dryRun(String,
|
||||
* QuotaRequestContext, long)} share the same implementation and {@link #refill(String,
|
||||
* QuotaRequestContext, long)} is a no-op.
|
||||
*/
|
||||
@ExtensionPoint
|
||||
public interface QuotaEnforcer {
|
||||
/**
|
||||
* Checks if there is at least {@code numTokens} quota to fulfil the request. Bucket-based
|
||||
* implementations can deduct the inquired number of tokens from the bucket.
|
||||
*/
|
||||
QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens);
|
||||
|
||||
/**
|
||||
* Checks if there is at least {@code numTokens} quota to fulfil the request. This is a pre-flight
|
||||
* request, implementations should not deduct tokens from a bucket, yet.
|
||||
*/
|
||||
QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens);
|
||||
|
||||
/**
|
||||
* A previously requested and deducted quota has to be refilled (if possible) because the request
|
||||
* failed other quota checks. Implementations can choose to leave this a no-op in case they are
|
||||
* the first line of defence (e.g. always deduct HTTP quota even if the request failed for other
|
||||
* quota issues so that the user gets throttled).
|
||||
*
|
||||
* <p>Will not be called if the {@link #requestTokens(String, QuotaRequestContext, long)} call
|
||||
* returned {@link QuotaResponse.Status#NO_OP}.
|
||||
*/
|
||||
void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens);
|
||||
}
|
||||
27
java/com/google/gerrit/server/quota/QuotaException.java
Normal file
27
java/com/google/gerrit/server/quota/QuotaException.java
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.server.quota;
|
||||
|
||||
import com.google.gerrit.extensions.restapi.RestApiException;
|
||||
|
||||
/**
|
||||
* Exception that was encountered while checking if there is sufficient quota to fulfil the request.
|
||||
* Can be propagated directly to the REST API.
|
||||
*/
|
||||
public class QuotaException extends RestApiException {
|
||||
public QuotaException(String reason) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
54
java/com/google/gerrit/server/quota/QuotaRequestContext.java
Normal file
54
java/com/google/gerrit/server/quota/QuotaRequestContext.java
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.server.quota;
|
||||
|
||||
import com.google.auto.value.AutoValue;
|
||||
import com.google.gerrit.reviewdb.client.Account;
|
||||
import com.google.gerrit.reviewdb.client.Change;
|
||||
import com.google.gerrit.reviewdb.client.Project;
|
||||
import com.google.gerrit.server.AnonymousUser;
|
||||
import com.google.gerrit.server.CurrentUser;
|
||||
import java.util.Optional;
|
||||
|
||||
@AutoValue
|
||||
public abstract class QuotaRequestContext {
|
||||
|
||||
public static Builder builder() {
|
||||
return new AutoValue_QuotaRequestContext.Builder().user(new AnonymousUser());
|
||||
}
|
||||
|
||||
public abstract CurrentUser user();
|
||||
|
||||
public abstract Optional<Project.NameKey> project();
|
||||
|
||||
public abstract Optional<Change.Id> change();
|
||||
|
||||
public abstract Optional<Account.Id> account();
|
||||
|
||||
public abstract Builder toBuilder();
|
||||
|
||||
@AutoValue.Builder
|
||||
public abstract static class Builder {
|
||||
public abstract QuotaRequestContext.Builder user(CurrentUser user);
|
||||
|
||||
public abstract QuotaRequestContext.Builder account(Account.Id account);
|
||||
|
||||
public abstract QuotaRequestContext.Builder project(Project.NameKey project);
|
||||
|
||||
public abstract QuotaRequestContext.Builder change(Change.Id change);
|
||||
|
||||
public abstract QuotaRequestContext build();
|
||||
}
|
||||
}
|
||||
113
java/com/google/gerrit/server/quota/QuotaResponse.java
Normal file
113
java/com/google/gerrit/server/quota/QuotaResponse.java
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (C) 2018 The Android Open Source Project
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package com.google.gerrit.server.quota;
|
||||
|
||||
import static com.google.common.collect.ImmutableList.toImmutableList;
|
||||
|
||||
import com.google.auto.value.AutoValue;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.Streams;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@AutoValue
|
||||
public abstract class QuotaResponse {
|
||||
public enum Status {
|
||||
/** The quota requests succeeded. */
|
||||
OK,
|
||||
|
||||
/**
|
||||
* The quota succeeded, but was a no-op because the plugin does not enforce this quota group
|
||||
* (equivalent to OK, but relevant for debugging).
|
||||
*/
|
||||
NO_OP,
|
||||
|
||||
/**
|
||||
* The requested quota could not be allocated. This status code is not used to indicate
|
||||
* processing failures as these are propagated as {@code RuntimeException}s.
|
||||
*/
|
||||
ERROR;
|
||||
|
||||
public boolean isOk() {
|
||||
return this == OK;
|
||||
}
|
||||
|
||||
public boolean isError() {
|
||||
return this == ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
public static QuotaResponse ok() {
|
||||
return new AutoValue_QuotaResponse.Builder().status(Status.OK).build();
|
||||
}
|
||||
|
||||
public static QuotaResponse noOp() {
|
||||
return new AutoValue_QuotaResponse.Builder().status(Status.NO_OP).build();
|
||||
}
|
||||
|
||||
public static QuotaResponse error(String message) {
|
||||
return new AutoValue_QuotaResponse.Builder().status(Status.ERROR).message(message).build();
|
||||
}
|
||||
|
||||
public abstract Status status();
|
||||
|
||||
public abstract Optional<String> message();
|
||||
|
||||
@AutoValue.Builder
|
||||
public abstract static class Builder {
|
||||
public abstract QuotaResponse.Builder status(Status status);
|
||||
|
||||
public abstract QuotaResponse.Builder message(String message);
|
||||
|
||||
public abstract QuotaResponse build();
|
||||
}
|
||||
|
||||
@AutoValue
|
||||
public abstract static class Aggregated {
|
||||
protected abstract ImmutableList<QuotaResponse> responses();
|
||||
|
||||
public boolean hasError() {
|
||||
return responses().stream().anyMatch(r -> r.status().isError());
|
||||
}
|
||||
|
||||
public ImmutableList<QuotaResponse> all() {
|
||||
return responses();
|
||||
}
|
||||
|
||||
public ImmutableList<QuotaResponse> ok() {
|
||||
return responses().stream().filter(r -> r.status().isOk()).collect(toImmutableList());
|
||||
}
|
||||
|
||||
public ImmutableList<QuotaResponse> error() {
|
||||
return responses().stream().filter(r -> r.status().isError()).collect(toImmutableList());
|
||||
}
|
||||
|
||||
public String errorMessage() {
|
||||
return error()
|
||||
.stream()
|
||||
.map(QuotaResponse::message)
|
||||
.flatMap(Streams::stream)
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
public void throwOnError() throws QuotaException {
|
||||
String errorMessage = errorMessage();
|
||||
if (!Strings.isNullOrEmpty(errorMessage)) {
|
||||
throw new QuotaException(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,5 +56,38 @@ public class RequestUtil {
|
||||
return pathInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims leading '/' and 'a/'. Removes the context path, but keeps the servlet path. Removes all
|
||||
* IDs from the rest of the URI.
|
||||
*
|
||||
* <p>The returned string is a good fit for cases where one wants the full context of the request
|
||||
* without any identifiable data. For example: Logging or quota checks.
|
||||
*
|
||||
* <p>Examples:
|
||||
*
|
||||
* <ul>
|
||||
* <li>/a/accounts/self/detail => /accounts/detail
|
||||
* <li>/changes/123/revisions/current/detail => /changes/revisions/detail
|
||||
* <li>/changes/ => /changes
|
||||
* </ul>
|
||||
*/
|
||||
public static String getRestPathWithoutIds(HttpServletRequest req) {
|
||||
String encodedPathInfo = req.getRequestURI().substring(req.getContextPath().length());
|
||||
if (encodedPathInfo.startsWith("/")) {
|
||||
encodedPathInfo = encodedPathInfo.substring(1);
|
||||
}
|
||||
if (encodedPathInfo.startsWith("a/")) {
|
||||
encodedPathInfo = encodedPathInfo.substring(2);
|
||||
}
|
||||
|
||||
String[] parts = encodedPathInfo.split("/");
|
||||
StringBuilder result = new StringBuilder(parts.length);
|
||||
for (int i = 0; i < parts.length; i = i + 2) {
|
||||
result.append("/");
|
||||
result.append(parts[i]);
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private RequestUtil() {}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
package com.google.gerrit.util.http;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
|
||||
import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
|
||||
|
||||
import com.google.gerrit.testing.GerritBaseTests;
|
||||
import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
|
||||
@@ -22,36 +24,45 @@ import org.junit.Test;
|
||||
|
||||
public class RequestUtilTest extends GerritBaseTests {
|
||||
@Test
|
||||
public void emptyContextPath() {
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar")))
|
||||
.isEqualTo("/foo/bar");
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar")))
|
||||
.isEqualTo("/foo%2Fbar");
|
||||
public void getEncodedPathInfo_emptyContextPath() {
|
||||
assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
|
||||
assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyServletPath() {
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar")))
|
||||
.isEqualTo("/foo/bar");
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar")))
|
||||
.isEqualTo("/foo%2Fbar");
|
||||
public void getEncodedPathInfo_emptyServletPath() {
|
||||
assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar"))).isEqualTo("/foo/bar");
|
||||
assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void trailingSlashes() {
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/")))
|
||||
.isEqualTo("/foo/bar/");
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///")))
|
||||
.isEqualTo("/foo/bar/");
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/")))
|
||||
.isEqualTo("/foo%2Fbar/");
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
|
||||
public void getEncodedPathInfo_trailingSlashes() {
|
||||
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/"))).isEqualTo("/foo/bar/");
|
||||
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///"))).isEqualTo("/foo/bar/");
|
||||
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/"))).isEqualTo("/foo%2Fbar/");
|
||||
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
|
||||
.isEqualTo("/foo%2Fbar/");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyPathInfo() {
|
||||
assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
|
||||
assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getRestPathWithoutIds_emptyContextPath() {
|
||||
assertThat(getRestPathWithoutIds(fakeRequest("", "/a/accounts", "/123/test")))
|
||||
.isEqualTo("/accounts/test");
|
||||
assertThat(getRestPathWithoutIds(fakeRequest("", "/accounts", "/123/test")))
|
||||
.isEqualTo("/accounts/test");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getRestPathWithoutIds_nonEmptyContextPath() {
|
||||
assertThat(getRestPathWithoutIds(fakeRequest("/c", "/a/accounts", "/123/test")))
|
||||
.isEqualTo("/accounts/test");
|
||||
assertThat(getRestPathWithoutIds(fakeRequest("/c", "/accounts", "/123/test")))
|
||||
.isEqualTo("/accounts/test");
|
||||
}
|
||||
|
||||
private FakeHttpServletRequest fakeRequest(
|
||||
|
||||
Reference in New Issue
Block a user