| // Copyright (C) 2023 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.deleteproject.fs; |
| |
| import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; |
| |
| import com.google.common.flogger.FluentLogger; |
| import com.google.common.io.MoreFiles; |
| import com.google.gerrit.common.UsedAt; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.extensions.api.changes.NotifyHandling; |
| import com.google.gerrit.extensions.events.ProjectDeletedListener; |
| import com.google.gerrit.extensions.registration.DynamicSet; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.googlesource.gerrit.plugins.deleteproject.TimeMachine; |
| import java.io.File; |
| import java.io.IOException; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.file.StandardCopyOption; |
| import java.time.ZoneId; |
| import java.time.format.DateTimeFormatter; |
| import java.util.Optional; |
| import javax.inject.Inject; |
| import org.apache.commons.io.FileUtils; |
| import org.eclipse.jgit.errors.RepositoryNotFoundException; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.lib.RepositoryCache; |
| |
| /** |
| * This class contains methods that remove a git repository from the filesystem and the jgit cache, |
| * and optionally notify downstream listeners. It can therefore be reused by other plugins who need |
| * to delete a git repository. |
| */ |
| public class RepositoryDelete { |
| |
| private final GitRepositoryManager repoManager; |
| |
| @Inject |
| public RepositoryDelete(GitRepositoryManager repoManager) { |
| this.repoManager = repoManager; |
| } |
| |
| private static final FluentLogger log = FluentLogger.forEnclosingClass(); |
| private static final DateTimeFormatter FORMAT = |
| DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneId.of("UTC")); |
| |
| /** |
| * Removes a git repository from the filesystem and the jgit cache and optionally notifies |
| * downstream listeners. You can choose if the git repo should either be deleted or archived. |
| * |
| * <p>In order to delete the git directory, the logic will first rename the directory, a two-step |
| * process involving moving all the files in a different directory, and immediately deleting that |
| * directory. This helps release any open file handlers, which would on NFS filesystems prevent |
| * the directory from being empty (and therefore deletion would fail). For more details see <a |
| * href="https://bugs.chromium.org/p/gerrit/issues/detail?id=16730">...</a> |
| * |
| * @param project - the git repo name that is eligible for deletion |
| * @param preserveGitRepository - if true, just remove the repo from the git cache, but keep the |
| * repo on disk. |
| * @param archiveDeletedRepos - if true, copy the repo to an archived path, and delete the |
| * original directory. |
| * @param archivedFolder - only used when `archiveDeletedRepos` is true, provides the archived |
| * directory. |
| * @param deletedListeners - a set of `ProjectDeletedListener`s - when provided these listeners |
| * will be notified when a directory is deleted. This is not used for archiving. |
| * @throws RepositoryNotFoundException - if the repository does not exist |
| * @throws IOException - if any of the underlying operations during repo deletion fails |
| */ |
| public void execute( |
| Project.NameKey project, |
| boolean preserveGitRepository, |
| boolean archiveDeletedRepos, |
| Optional<Path> archivedFolder, |
| DynamicSet<ProjectDeletedListener> deletedListeners) |
| throws RepositoryNotFoundException, IOException { |
| Repository repository = repoManager.openRepository(project); |
| cleanCache(repository); |
| if (!preserveGitRepository) { |
| Path repoPath = repository.getDirectory().toPath(); |
| String projectName = project.get(); |
| if (archiveDeletedRepos) { |
| archiveGitRepository(projectName, repoPath, archivedFolder, deletedListeners); |
| } else { |
| deleteGitRepository(projectName, repoPath, deletedListeners); |
| } |
| } |
| } |
| |
| /** |
| * Removes a git repository from the filesystem and the jgit cache. The git repo is neither |
| * preserved (ie kept on disk) nor archived, and no downstream listeners are notified. |
| * |
| * @param project - the git repo name that is eligible for deletion |
| * @throws RepositoryNotFoundException - if the repository does not exist |
| * @throws IOException - if any of the underlying operations during repo deletion fails |
| */ |
| @UsedAt(UsedAt.Project.PLUGIN_PULL_REPLICATION) |
| public void execute(Project.NameKey project) throws RepositoryNotFoundException, IOException { |
| execute(project, false, false, Optional.empty(), DynamicSet.emptySet()); |
| } |
| |
| private static void cleanCache(Repository repository) { |
| repository.close(); |
| RepositoryCache.close(repository); |
| } |
| |
| private static void archiveGitRepository( |
| String projectName, Path repoPath, Optional<Path> archivedFolder, DynamicSet<ProjectDeletedListener> deletedListeners) throws IOException { |
| Path basePath = getBasePath(repoPath, projectName); |
| if (archivedFolder.isEmpty()) { |
| throw new IllegalArgumentException( |
| "An archive path must be provided for the " + basePath + " repo to be archived"); |
| } |
| Path renamedProjectDir = renameRepository(repoPath, basePath, projectName, "archived"); |
| try { |
| Path archive = getArchivePath(archivedFolder.get(), renamedProjectDir, basePath); |
| FileUtils.copyDirectory(renamedProjectDir.toFile(), archive.toFile()); |
| MoreFiles.deleteRecursively(renamedProjectDir, ALLOW_INSECURE); |
| } catch (IOException e) { |
| log.atWarning().withCause(e).log("Error trying to archive %s", renamedProjectDir); |
| } finally { |
| sendProjectDeletedEvent(projectName, deletedListeners); |
| } |
| } |
| |
| private static Path getArchivePath(Path archivedFolder, Path renamedProjectDir, Path basePath) { |
| Path configArchiveRepo = archivedFolder.toAbsolutePath(); |
| Path relativePath = basePath.relativize(renamedProjectDir); |
| return configArchiveRepo.resolve(relativePath); |
| } |
| |
| private static void deleteGitRepository( |
| String projectName, Path repoPath, DynamicSet<ProjectDeletedListener> deletedListeners) |
| throws IOException { |
| // Delete the repository from disk |
| Path basePath = getBasePath(repoPath, projectName); |
| Path trash = renameRepository(repoPath, basePath, projectName, "deleted"); |
| try { |
| MoreFiles.deleteRecursively(trash, ALLOW_INSECURE); |
| recursivelyDeleteEmptyParents(repoPath.toFile().getParentFile(), basePath.toFile()); |
| } catch (IOException e) { |
| // Only log if delete failed - repo already moved to trash. |
| log.atWarning().withCause(e).log("Error trying to delete %s or its parents", trash); |
| } finally { |
| sendProjectDeletedEvent(projectName, deletedListeners); |
| } |
| } |
| |
| private static Path getBasePath(Path repo, String projectName) { |
| Path projectPath = Path.of(projectName); |
| return repo.getRoot() |
| .resolve(repo.subpath(0, repo.getNameCount() - projectPath.getNameCount())); |
| } |
| |
| private static Path renameRepository( |
| Path directory, Path basePath, String projectName, String option) throws IOException { |
| Path newRepo = |
| basePath.resolve( |
| projectName + "." + FORMAT.format(TimeMachine.now()) + ".%" + option + "%.git"); |
| return Files.move(directory, newRepo, StandardCopyOption.ATOMIC_MOVE); |
| } |
| |
| /** |
| * Recursively delete the specified file and its parent files until we hit the file {@code Until} |
| * or the parent file is populated. This is used when we have a tree structure such as a/b/c/d.git |
| * and a/b/e.git - if we delete a/b/c/d.git, we no longer need a/b/c/. |
| */ |
| private static void recursivelyDeleteEmptyParents(File file, File until) throws IOException { |
| if (file.equals(until)) { |
| return; |
| } |
| if (file.listFiles().length == 0) { |
| File parent = file.getParentFile(); |
| Files.delete(file.toPath()); |
| recursivelyDeleteEmptyParents(parent, until); |
| } |
| } |
| |
| private static void sendProjectDeletedEvent( |
| String projectName, DynamicSet<ProjectDeletedListener> deletedListeners) { |
| if (!deletedListeners.iterator().hasNext()) { |
| return; |
| } |
| ProjectDeletedListener.Event event = |
| new ProjectDeletedListener.Event() { |
| @Override |
| public String getProjectName() { |
| return projectName; |
| } |
| |
| @Override |
| public NotifyHandling getNotify() { |
| return NotifyHandling.NONE; |
| } |
| }; |
| for (ProjectDeletedListener l : deletedListeners) { |
| try { |
| l.onProjectDeleted(event); |
| } catch (RuntimeException e) { |
| log.atWarning().withCause(e).log("Failure in ProjectDeletedListener"); |
| } |
| } |
| } |
| } |