Handle HEAD update in replicas Bug: Issue 15278 Change-Id: I2211201f23f2e787c21a47307fb566280bfe8a20
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java new file mode 100644 index 0000000..2437d80 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java
@@ -0,0 +1,54 @@ +// Copyright (C) 2021 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.googlesource.gerrit.plugins.replication.pull; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.Optional; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.transport.URIish; + +@Singleton +public class GerritConfigOps { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final SitePaths sitePath; + private final Config gerritConfig; + + @Inject + public GerritConfigOps(@GerritServerConfig Config cfg, SitePaths sitePath) { + this.sitePath = sitePath; + this.gerritConfig = cfg; + } + + public Optional<URIish> getGitRepositoryURI(String projectName) { + Path basePath = sitePath.resolve(gerritConfig.getString("gerrit", null, "basePath")); + URIish uri; + + try { + uri = new URIish("file://" + basePath + "/" + projectName); + return Optional.of(uri); + } catch (URISyntaxException e) { + logger.atSevere().withCause(e).log("Unsupported URI for project " + projectName); + } + + return Optional.empty(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java index 8f5c9d0..b7749da 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
@@ -23,21 +23,17 @@ import com.google.gerrit.entities.RefNames; import com.google.gerrit.extensions.restapi.Url; import com.google.gerrit.server.CurrentUser; -import com.google.gerrit.server.config.GerritServerConfig; -import com.google.gerrit.server.config.SitePaths; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.Singleton; import com.googlesource.gerrit.plugins.replication.LocalFS; +import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps; import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Path; import java.util.Optional; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.transport.URIish; @Singleton @@ -47,15 +43,12 @@ public static final String PROJECT_NAME = "project-name"; - private final SitePaths sitePath; - private final Config gerritConfig; + private final GerritConfigOps gerritConfigOps; private final Provider<CurrentUser> userProvider; @Inject - ProjectInitializationAction( - @GerritServerConfig Config cfg, SitePaths sitePath, Provider<CurrentUser> userProvider) { - this.sitePath = sitePath; - this.gerritConfig = cfg; + ProjectInitializationAction(GerritConfigOps gerritConfigOps, Provider<CurrentUser> userProvider) { + this.gerritConfigOps = gerritConfigOps; this.userProvider = userProvider; } @@ -94,7 +87,7 @@ } protected boolean initProject(String projectName) { - Optional<URIish> maybeUri = getGitRepositoryURI(projectName); + Optional<URIish> maybeUri = gerritConfigOps.getGitRepositoryURI(projectName); if (!maybeUri.isPresent()) { logger.atSevere().log("Cannot initialize project '{}'", projectName); return false; @@ -104,20 +97,6 @@ return localFS.createProject(projectNameKey, RefNames.HEAD); } - private Optional<URIish> getGitRepositoryURI(String projectName) { - Path basePath = sitePath.resolve(gerritConfig.getString("gerrit", null, "basePath")); - URIish uri; - - try { - uri = new URIish("file://" + basePath + "/" + projectName); - return Optional.of(uri); - } catch (URISyntaxException e) { - logger.atSevere().withCause(e).log("Unsupported URI for project " + projectName); - } - - return Optional.empty(); - } - public static String getProjectInitializationUrl(String pluginName, String projectName) { return String.format( "a/plugins/%s/init-project/%s", pluginName, Url.encode(projectName) + ".git");
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java index 1425be6..9831704 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
@@ -27,6 +27,7 @@ import com.google.common.base.Splitter; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.api.projects.HeadInput; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.IdString; @@ -74,6 +75,7 @@ private FetchAction fetchAction; private ApplyObjectAction applyObjectAction; private ProjectInitializationAction projectInitializationAction; + private UpdateHeadAction updateHEADAction; private ProjectsCollection projectsCollection; private Gson gson; private Provider<CurrentUser> userProvider; @@ -83,11 +85,13 @@ FetchAction fetchAction, ApplyObjectAction applyObjectAction, ProjectInitializationAction projectInitializationAction, + UpdateHeadAction updateHEADAction, ProjectsCollection projectsCollection, Provider<CurrentUser> userProvider) { this.fetchAction = fetchAction; this.applyObjectAction = applyObjectAction; this.projectInitializationAction = projectInitializationAction; + this.updateHEADAction = updateHEADAction; this.projectsCollection = projectsCollection; this.userProvider = userProvider; this.gson = OutputFormat.JSON.newGsonBuilder().create(); @@ -125,6 +129,12 @@ } else { httpResponse.sendError(SC_UNAUTHORIZED); } + } else if (isUpdateHEADAction(httpRequest)) { + if (userProvider.get().isIdentifiedUser()) { + writeResponse(httpResponse, doUpdateHEAD(httpRequest)); + } else { + httpResponse.sendError(SC_UNAUTHORIZED); + } } else { chain.doFilter(request, response); } @@ -176,6 +186,15 @@ } @SuppressWarnings("unchecked") + private Response<String> doUpdateHEAD(HttpServletRequest httpRequest) throws Exception { + HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class)); + ProjectResource projectResource = + projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest)); + + return (Response<String>) updateHEADAction.apply(projectResource, input); + } + + @SuppressWarnings("unchecked") private Response<Map<String, Object>> doFetch(HttpServletRequest httpRequest) throws IOException, RestApiException, PermissionBackendException { Input input = readJson(httpRequest, TypeLiteral.get(Input.class)); @@ -185,8 +204,8 @@ return (Response<Map<String, Object>>) fetchAction.apply(projectResource, input); } - private void writeResponse( - HttpServletResponse httpResponse, Response<Map<String, Object>> response) throws IOException { + private <T> void writeResponse(HttpServletResponse httpResponse, Response<T> response) + throws IOException { String responseJson = gson.toJson(response); if (response.statusCode() == SC_OK || response.statusCode() == SC_CREATED) { @@ -260,4 +279,9 @@ private boolean isInitProjectAction(HttpServletRequest httpRequest) { return httpRequest.getRequestURI().contains("pull-replication/init-project/"); } + + private boolean isUpdateHEADAction(HttpServletRequest httpRequest) { + return httpRequest.getRequestURI().matches("(/a)?/projects/[^/]+/HEAD") + && "PUT".equals(httpRequest.getMethod()); + } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java new file mode 100644 index 0000000..4195435 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java
@@ -0,0 +1,79 @@ +// Copyright (C) 2021 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.googlesource.gerrit.plugins.replication.pull.api; + +import com.google.common.base.Strings; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.api.projects.HeadInput; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.ResourceNotFoundException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.RefPermission; +import com.google.gerrit.server.project.ProjectResource; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.googlesource.gerrit.plugins.replication.LocalFS; +import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps; +import java.util.Optional; +import org.eclipse.jgit.transport.URIish; + +@Singleton +public class UpdateHeadAction implements RestModifyView<ProjectResource, HeadInput> { + private final GerritConfigOps gerritConfigOps; + private final PermissionBackend permissionBackend; + + @Inject + UpdateHeadAction(GerritConfigOps gerritConfigOps, PermissionBackend permissionBackend) { + this.gerritConfigOps = gerritConfigOps; + this.permissionBackend = permissionBackend; + } + + @Override + public Response<?> apply(ProjectResource projectResource, HeadInput input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + if (input == null || Strings.isNullOrEmpty(input.ref)) { + throw new BadRequestException("ref required"); + } + String ref = RefNames.fullName(input.ref); + + permissionBackend + .user(projectResource.getUser()) + .project(projectResource.getNameKey()) + .ref(ref) + .check(RefPermission.SET_HEAD); + + // TODO: the .git suffix should not be added here, but rather it should be + // dealt with by the caller, honouring the naming style from the + // replication.config (Issue 15221) + Optional<URIish> maybeRepo = + gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName())); + + if (maybeRepo.isPresent()) { + if (new LocalFS(maybeRepo.get()).updateHead(projectResource.getNameKey(), ref)) { + return Response.ok(ref); + } + throw new UnprocessableEntityException( + String.format( + "Could not update HEAD of repo %s to ref %s", projectResource.getName(), ref)); + } + throw new ResourceNotFoundException( + String.format("Could not compute URL for repo: %s", projectResource.getName())); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java index 49cf608..94e7fd5 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
@@ -20,6 +20,7 @@ import com.google.gerrit.acceptance.LightweightPluginDaemonTest; import com.google.gerrit.acceptance.PushOneCommit.Result; import com.google.gerrit.acceptance.SkipProjectClone; +import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.TestPlugin; import com.google.gerrit.acceptance.UseLocalDisk; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; @@ -48,6 +49,7 @@ import org.apache.http.client.CredentialsProvider; import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; import org.apache.http.client.protocol.HttpClientContext; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; @@ -115,6 +117,13 @@ return post; } + protected HttpPut createPutRequest(String sendObjectPayload) { + HttpPut put = new HttpPut(url); + put.setEntity(new StringEntity(sendObjectPayload, StandardCharsets.UTF_8)); + put.addHeader(new BasicHeader("Content-Type", "application/json")); + return put; + } + protected String createRef() throws Exception { return createRef(Project.nameKey(project + TEST_REPLICATION_SUFFIX)); } @@ -152,12 +161,11 @@ } protected HttpClientContext getContext() { - HttpClientContext ctx = HttpClientContext.create(); - CredentialsProvider adapted = new BasicCredentialsProvider(); - adapted.setCredentials( - AuthScope.ANY, new UsernamePasswordCredentials(admin.username(), admin.httpPassword())); - ctx.setCredentialsProvider(adapted); - return ctx; + return getContextForAccount(admin); + } + + protected HttpClientContext getUserContext() { + return getContextForAccount(user); } protected HttpClientContext getAnonymousContext() { @@ -199,4 +207,13 @@ secureConfig.setString("remote", remoteName, "password", password); secureConfig.save(); } + + private HttpClientContext getContextForAccount(TestAccount account) { + HttpClientContext ctx = HttpClientContext.create(); + CredentialsProvider adapted = new BasicCredentialsProvider(); + adapted.setCredentials( + AuthScope.ANY, new UsernamePasswordCredentials(account.username(), account.httpPassword())); + ctx.setCredentialsProvider(adapted); + return ctx; + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java new file mode 100644 index 0000000..18e002d --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java
@@ -0,0 +1,167 @@ +// Copyright (C) 2021 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.googlesource.gerrit.plugins.replication.pull.api; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.entities.Permission; +import com.google.gerrit.extensions.api.projects.BranchInput; +import com.google.gerrit.extensions.api.projects.HeadInput; +import com.google.gson.Gson; +import com.google.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import org.junit.Test; + +public class UpdateHeadActionIT extends ActionITBase { + private static final Gson gson = newGson(); + + @Inject private ProjectOperations projectOperations; + + @Test + public void shouldReturnUnauthorizedForUserWithoutPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/branch")), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Test + public void shouldReturnBadRequestWhenInputIsEmpty() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("")), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + public void shouldReturnOKWhenHeadIsUpdated() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getContext()); + + assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnBadRequestWhenInputIsEmptyInReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("")), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnOKWhenHeadIsUpdatedInReplica() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getContext()); + + assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch); + } + + @Test + public void shouldReturnForbiddenWhenMissingPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/new/head")), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test + public void shouldReturnOKWhenRegisteredUserHasPermissions() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnForbiddenWhenMissingPermissionsInReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/new/head")), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + private String headInput(String ref) { + HeadInput headInput = new HeadInput(); + headInput.ref = ref; + return gson.toJson(headInput); + } + + @Override + protected String getURL() { + return String.format("%s/a/projects/%s/HEAD", adminRestSession.url(), project.get()); + } +}