blob: 19d5b71b33bf26672efaab85565e567fad5de77d [file] [log] [blame]
// 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.googlesource.gerrit.plugins.renameproject;
import static com.googlesource.gerrit.plugins.renameproject.RenameOwnProjectCapability.RENAME_OWN_PROJECT;
import static com.googlesource.gerrit.plugins.renameproject.RenameProjectCapability.RENAME_PROJECT;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Change.Id;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.api.access.PluginPermission;
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.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.GerritIsReplica;
import com.google.gerrit.server.extensions.events.PluginEvent;
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.permissions.ProjectPermission;
import com.google.gerrit.server.project.ProjectResource;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import com.googlesource.gerrit.plugins.renameproject.RenameProject.Input;
import com.googlesource.gerrit.plugins.renameproject.cache.CacheRenameHandler;
import com.googlesource.gerrit.plugins.renameproject.conditions.RenamePreconditions;
import com.googlesource.gerrit.plugins.renameproject.database.DatabaseRenameHandler;
import com.googlesource.gerrit.plugins.renameproject.database.IndexUpdateHandler;
import com.googlesource.gerrit.plugins.renameproject.fs.FilesystemRenameHandler;
import com.googlesource.gerrit.plugins.renameproject.monitor.ProgressMonitor;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.apache.http.auth.AuthenticationException;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.transport.URIish;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class RenameProject implements RestModifyView<ProjectResource, Input> {
@Override
public Response<?> apply(ProjectResource resource, Input input)
throws IOException, AuthException, BadRequestException, ResourceConflictException,
InterruptedException, ConfigInvalidException, RenameRevertException {
Optional<ProgressMonitor> optionalProgressMonitor = Optional.empty();
assertCanRename(resource, input, optionalProgressMonitor);
List<Id> changeIds = getChanges(resource, optionalProgressMonitor);
if (startRename(
resource,
input,
Optional.empty(),
(changeIds == null || changeIds.size() <= WARNING_LIMIT || input.continueWithRename),
changeIds)) {
return Response.ok("");
}
return Response.none();
}
public boolean startRename(
ProjectResource resource,
Input input,
Optional<ProgressMonitor> optionalProgressMonitor,
boolean continueRename,
List<Change.Id> changeIds)
throws ResourceConflictException, BadRequestException, AuthException, IOException,
ConfigInvalidException, RenameRevertException, InterruptedException {
if (!isReplica) {
if (continueRename) {
doRename(Optional.ofNullable(changeIds), resource, input, optionalProgressMonitor);
} else {
log.debug(CANCELLATION_MSG);
return false;
}
} else {
doRename(Optional.empty(), resource, input, Optional.empty());
}
return true;
}
public static class Input {
String name;
boolean continueWithRename;
}
static final int WARNING_LIMIT = 5000;
static final String CANCELLATION_MSG =
"Rename cancelled due to number of changes exceeding warning limit and user's will to not continue";
private static final Logger log = LoggerFactory.getLogger(RenameProject.class);
private static final String CACHE_NAME = "changeid_project";
private static final String WITH_AUTHENTICATION = "a";
public static final String RENAME_ACTION = "rename";
public static final String PROJECTS_ENDPOINT = "projects";
private final DatabaseRenameHandler dbHandler;
private final FilesystemRenameHandler fsHandler;
private final CacheRenameHandler cacheHandler;
private final RenamePreconditions renamePreconditions;
private final IndexUpdateHandler indexHandler;
private final Provider<CurrentUser> userProvider;
private final LockUnlockProject lockUnlockProject;
private final PluginEvent pluginEvent;
private final String pluginName;
private final RenameLog renameLog;
private final boolean isReplica;
private final PermissionBackend permissionBackend;
private final Cache<Change.Id, String> changeIdProjectCache;
private final RevertRenameProject revertRenameProject;
private final SshHelper sshHelper;
private HttpSession httpSession;
private final Configuration cfg;
private List<Step> stepsPerformed;
@Inject
RenameProject(
DatabaseRenameHandler dbHandler,
FilesystemRenameHandler fsHandler,
CacheRenameHandler cacheHandler,
RenamePreconditions renamePreconditions,
IndexUpdateHandler indexHandler,
Provider<CurrentUser> userProvider,
LockUnlockProject lockUnlockProject,
PluginEvent pluginEvent,
@PluginName String pluginName,
@GerritIsReplica Boolean isReplica,
RenameLog renameLog,
PermissionBackend permissionBackend,
@Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
RevertRenameProject revertRenameProject,
SshHelper sshHelper,
HttpSession httpSession,
Configuration cfg) {
this.dbHandler = dbHandler;
this.fsHandler = fsHandler;
this.cacheHandler = cacheHandler;
this.renamePreconditions = renamePreconditions;
this.indexHandler = indexHandler;
this.userProvider = userProvider;
this.lockUnlockProject = lockUnlockProject;
this.pluginEvent = pluginEvent;
this.pluginName = pluginName;
this.renameLog = renameLog;
this.permissionBackend = permissionBackend;
this.changeIdProjectCache = changeIdProjectCache;
this.revertRenameProject = revertRenameProject;
this.sshHelper = sshHelper;
this.httpSession = httpSession;
this.cfg = cfg;
this.stepsPerformed = new ArrayList<>();
this.isReplica = isReplica;
}
private void assertNewNameNotNull(Input input) throws BadRequestException {
if (input == null || Strings.isNullOrEmpty(input.name)) {
throw new BadRequestException("Name of the repo cannot be null or empty");
}
}
private void assertNewNameMatchesRegex(Input input) throws BadRequestException {
if (!input.name.matches(cfg.getRenameRegex())) {
throw new BadRequestException(
String.format(
"Name of the repo should match the expected regex: %s", cfg.getRenameRegex()));
}
}
private void assertRenamePermission(ProjectResource rsrc) throws AuthException {
if ((isReplica && !isAdmin()) || !canRename(rsrc)) {
throw new AuthException("Not allowed to rename project");
}
}
private PermissionBackend.WithUser getUserPermissions() {
return permissionBackend.user(userProvider.get());
}
protected boolean canRename(ProjectResource rsrc) {
PermissionBackend.WithUser userPermission = getUserPermissions();
return isAdmin()
|| userPermission.testOrFalse(new PluginPermission(pluginName, RENAME_PROJECT))
|| (userPermission.testOrFalse(new PluginPermission(pluginName, RENAME_OWN_PROJECT))
&& isOwner(rsrc));
}
boolean isAdmin() {
return getUserPermissions().testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
}
private boolean isOwner(ProjectResource project) {
try {
permissionBackend
.user(project.getUser())
.project(project.getNameKey())
.check(ProjectPermission.WRITE_CONFIG);
} catch (AuthException | PermissionBackendException noWriter) {
try {
permissionBackend.user(project.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
} catch (AuthException | PermissionBackendException noAdmin) {
return false;
}
}
return true;
}
void assertCanRename(ProjectResource rsrc, Input input, Optional<ProgressMonitor> opm)
throws ResourceConflictException, BadRequestException, AuthException {
try {
opm.ifPresent(progressMonitor -> progressMonitor.beginTask("Checking preconditions"));
assertNewNameNotNull(input);
assertNewNameMatchesRegex(input);
assertRenamePermission(rsrc);
if (!isReplica) {
renamePreconditions.assertCanRename(rsrc, Project.nameKey(input.name));
}
log.debug("Rename preconditions check successful.");
} catch (CannotRenameProjectException e) {
throw new ResourceConflictException(e.getMessage());
}
}
void doRename(
Optional<List<Change.Id>> opChangeIds,
ProjectResource rsrc,
Input input,
Optional<ProgressMonitor> opm)
throws InterruptedException, ConfigInvalidException, IOException, RenameRevertException {
Project.NameKey oldProjectKey = rsrc.getNameKey();
Project.NameKey newProjectKey = Project.nameKey(input.name);
Exception ex = null;
stepsPerformed.clear();
try {
fsRenameStep(oldProjectKey, newProjectKey, opm);
if (!isReplica) {
cacheRenameStep(rsrc.getNameKey(), newProjectKey);
List<Change.Id> updatedChangeIds =
dbRenameStep(opChangeIds.get(), oldProjectKey, newProjectKey, opm);
// if the DB update is successful, update the secondary index
indexRenameStep(updatedChangeIds, oldProjectKey, newProjectKey, opm);
// no need to revert this since newProjectKey will be removed from project cache before
lockUnlockProject.unlock(newProjectKey);
log.debug("Unlocked the repo {} after rename operation.", newProjectKey.get());
// flush old changeId -> Project cache for given changeIds
changeIdProjectCache.invalidateAll(opChangeIds.get());
pluginEvent.fire(pluginName, pluginName, oldProjectKey.get() + ":" + newProjectKey.get());
// replicate rename-project operation to other replica instances
replicateRename(sshHelper, httpSession, input, oldProjectKey, opm);
}
} catch (Exception e) {
if (stepsPerformed.isEmpty()) {
log.error("Renaming procedure failed. Exception caught: {}", e.toString());
} else {
log.error(
"Renaming procedure failed, last successful step {}. Exception caught: {}",
stepsPerformed.get(stepsPerformed.size() - 1).toString(),
e.toString());
}
try {
revertRenameProject.performRevert(
stepsPerformed, opChangeIds.get(), oldProjectKey, newProjectKey, opm);
} catch (Exception revertEx) {
log.error(
"Failed to revert renaming procedure for {}. Exception caught: {}",
oldProjectKey.get(),
revertEx.toString());
ex = revertEx;
throw new RenameRevertException(revertEx, e);
}
ex = e;
throw e;
} finally {
renameLog.onRename((IdentifiedUser) userProvider.get(), oldProjectKey, input, ex);
}
}
void fsRenameStep(
Project.NameKey oldProjectKey, Project.NameKey newProjectKey, Optional<ProgressMonitor> opm)
throws IOException {
fsHandler.rename(oldProjectKey, newProjectKey, opm);
logPerformedStep(Step.FILESYSTEM, newProjectKey, oldProjectKey);
}
void cacheRenameStep(Project.NameKey oldProjectKey, Project.NameKey newProjectKey)
throws IOException {
cacheHandler.update(oldProjectKey, newProjectKey);
logPerformedStep(Step.CACHE, newProjectKey, oldProjectKey);
}
List<Change.Id> dbRenameStep(
List<Change.Id> changeIds,
Project.NameKey oldProjectKey,
Project.NameKey newProjectKey,
Optional<ProgressMonitor> opm)
throws IOException, ConfigInvalidException, RenameRevertException {
List<Change.Id> updatedChangeIds = dbHandler.rename(changeIds, newProjectKey, opm);
logPerformedStep(Step.DATABASE, newProjectKey, oldProjectKey);
return updatedChangeIds;
}
void indexRenameStep(
List<Change.Id> updatedChangeIds,
Project.NameKey oldProjectKey,
Project.NameKey newProjectKey,
Optional<ProgressMonitor> opm)
throws InterruptedException {
indexHandler.updateIndex(updatedChangeIds, newProjectKey, opm);
logPerformedStep(Step.INDEX, newProjectKey, oldProjectKey);
}
enum Step {
FILESYSTEM,
CACHE,
DATABASE,
INDEX
}
private void logPerformedStep(
Step step, Project.NameKey newProjectKey, Project.NameKey oldProjectKey) {
stepsPerformed.add(step);
switch (step) {
case FILESYSTEM:
log.debug("Renamed the git repo to {} successfully.", newProjectKey.get());
break;
case CACHE:
log.debug("Successfully updated project cache for project {}.", newProjectKey.get());
break;
case DATABASE:
log.debug("Updated the changes in DB successfully for project {}.", oldProjectKey.get());
break;
case INDEX:
log.debug("Updated the secondary index successfully for project {}.", oldProjectKey.get());
}
}
@VisibleForTesting
List<Step> getStepsPerformed() {
return stepsPerformed;
}
List<Change.Id> getChanges(ProjectResource rsrc, Optional<ProgressMonitor> opm)
throws IOException {
opm.ifPresent(pm -> pm.beginTask("Retrieving the list of changes from DB"));
Project.NameKey oldProjectKey = rsrc.getNameKey();
return dbHandler.getChangeIds(oldProjectKey);
}
void replicateRename(
SshHelper sshHelper,
HttpSession httpSession,
Input input,
Project.NameKey oldProjectKey,
Optional<ProgressMonitor> opm) {
opm.ifPresent(
pm ->
pm.beginTask(
String.format(
"Replicating the rename of %s to %s", oldProjectKey.get(), input.name)));
Set<String> urls = cfg.getUrls();
int nbRetries = cfg.getRenameReplicationRetries();
for (int i = 0; i < nbRetries && urls.size() > 0; ++i) {
urls = tryRenameReplication(urls, sshHelper, httpSession, input, oldProjectKey);
}
for (String url : urls) {
log.error(
"Failed to replicate the renaming of {} to {} on {} during {} attempts",
oldProjectKey.get(),
input.name,
url,
cfg.getUrls());
}
}
void sshReplicateRename(
SshHelper sshHelper, Input input, Project.NameKey oldProjectKey, String url)
throws RenameReplicationException, URISyntaxException, IOException {
OutputStream errStream = sshHelper.newErrorBufferStream();
sshHelper.executeRemoteSsh(
new URIish(url), pluginName + " " + oldProjectKey.get() + " " + input.name, errStream);
String errorMessage = errStream.toString();
if (!errorMessage.isEmpty()) {
throw new RenameReplicationException(errorMessage);
}
}
void httpReplicateRename(
HttpSession httpSession, Input input, Project.NameKey oldProjectKey, String url)
throws AuthenticationException, IOException, RenameReplicationException {
String request =
Joiner.on("/")
.join(
url,
WITH_AUTHENTICATION,
PROJECTS_ENDPOINT,
oldProjectKey.get(),
pluginName + "~" + RENAME_ACTION);
HttpResponseHandler.HttpResult result = httpSession.post(request, input);
if (!result.isSuccessful()) {
throw new RenameReplicationException(
String.format("Unable to replicate rename to %s : %s", url, result.getMessage()));
}
}
private Set<String> tryRenameReplication(
Set<String> replicas,
SshHelper sshHelper,
HttpSession httpSession,
Input input,
Project.NameKey oldProjectKey) {
Set<String> failedReplicas = new HashSet<>();
for (String url : replicas) {
try {
if (url.matches("http(.*)")) {
httpReplicateRename(httpSession, input, oldProjectKey, url);
}
if (url.matches("ssh(.*)")) {
sshReplicateRename(sshHelper, input, oldProjectKey, url);
}
} catch (AuthenticationException
| IOException
| URISyntaxException
| RenameReplicationException e) {
log.info(
"Rescheduling a rename replication for retry for {} on project {}",
url,
oldProjectKey.get());
e.printStackTrace();
failedReplicas.add(url);
}
}
return failedReplicas;
}
}