Merge branch 'stable-3.8' * stable-3.8: Move gr-overlay to dialog element for delete-project plugin Change-Id: I8f11ac208695ee3cac8812d6e472059225f47b98
diff --git a/.gitignore b/.gitignore index 81322bc..8967117 100644 --- a/.gitignore +++ b/.gitignore
@@ -5,3 +5,7 @@ /bazel-* /eclipse-out /node_modules + +# IntelliJ files +.idea +*.iml \ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java index 459d819..47feddf 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
@@ -14,8 +14,12 @@ package com.googlesource.gerrit.plugins.deleteproject; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.extensions.webui.UiAction; import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.config.GerritInstanceId; +import com.google.gerrit.server.events.EventDispatcher; import com.google.gerrit.server.project.ProjectResource; import com.google.inject.Inject; import com.google.inject.Provider; @@ -36,7 +40,9 @@ DeleteLog deleteLog, DeletePreconditions preConditions, Configuration cfg, - HideProject hideProject) { + HideProject hideProject, + DynamicItem<EventDispatcher> dispatcher, + @Nullable @GerritInstanceId String instanceId) { super( dbHandler, fsHandler, @@ -45,7 +51,9 @@ deleteLog, preConditions, cfg, - hideProject); + hideProject, + dispatcher, + instanceId); this.protectedProjects = protectedProjects; }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java index 40463b2..75a948a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
@@ -14,13 +14,17 @@ package com.googlesource.gerrit.plugins.deleteproject; +import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; 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.GerritInstanceId; +import com.google.gerrit.server.events.EventDispatcher; import com.google.gerrit.server.project.ProjectResource; import com.google.inject.Inject; import com.google.inject.Provider; @@ -48,6 +52,8 @@ private final DeleteLog deleteLog; private final Configuration cfg; private final HideProject hideProject; + private final DynamicItem<EventDispatcher> dispatcher; + private final String instanceId; @Inject DeleteProject( @@ -58,7 +64,9 @@ DeleteLog deleteLog, DeletePreconditions preConditions, Configuration cfg, - HideProject hideProject) { + HideProject hideProject, + DynamicItem<EventDispatcher> dispatcher, + @Nullable @GerritInstanceId String instanceId) { this.dbHandler = dbHandler; this.fsHandler = fsHandler; this.cacheHandler = cacheHandler; @@ -67,6 +75,8 @@ this.preConditions = preConditions; this.cfg = cfg; this.hideProject = hideProject; + this.dispatcher = dispatcher; + this.instanceId = instanceId; } @Override @@ -94,6 +104,20 @@ } else { hideProject.apply(rsrc); } + + ProjectDeletedEvent event = new ProjectDeletedEvent(); + event.projectName = project.getName(); + event.instanceId = instanceId; + + /** + * EventBroker checks if user has the permission to access the project. But because this + * project is already deleted, check will always fail. That's why this event will be delivered + * only to the unrestricted listeners. Unrestricted events listeners are implementing {@link + * com.google.gerrit.server.events.EventListener} which allows to listen to events without + * user visibility restrictions. For example these events are going to be delivered to + * multi-site {@link com.googlesource.gerrit.plugins.multisite.event.EventHandler} + */ + dispatcher.get().postEvent(project.getNameKey(), event); } catch (Exception e) { ex = e; throw e;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java index 4d0a802..a2e3f15 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/PluginModule.java
@@ -22,6 +22,7 @@ import com.google.gerrit.extensions.config.CapabilityDefinition; import com.google.gerrit.extensions.events.LifecycleListener; import com.google.gerrit.extensions.restapi.RestApiModule; +import com.google.gerrit.server.events.EventTypes; import com.google.inject.AbstractModule; import com.google.inject.Inject; import com.google.inject.internal.UniqueAnnotations; @@ -60,6 +61,8 @@ .to(ArchiveRepositoryRemover.class); } + EventTypes.register(ProjectDeletedEvent.TYPE, ProjectDeletedEvent.class); + install( new RestApiModule() { @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/ProjectDeletedEvent.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/ProjectDeletedEvent.java new file mode 100644 index 0000000..31a41cc --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/ProjectDeletedEvent.java
@@ -0,0 +1,33 @@ +// Copyright (C) 2022 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; + +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.server.events.ProjectEvent; + +public class ProjectDeletedEvent extends ProjectEvent { + public static final String TYPE = "project-deleted"; + public String projectName; + + public ProjectDeletedEvent() { + super(TYPE); + } + + @Override + public NameKey getProjectNameKey() { + return Project.nameKey(projectName); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java index 053b2fc..80d4eea 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java
@@ -14,156 +14,37 @@ 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.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.google.inject.Inject; import com.googlesource.gerrit.plugins.deleteproject.Configuration; -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.Paths; -import java.nio.file.StandardCopyOption; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import org.apache.commons.io.FileUtils; +import java.util.Optional; import org.eclipse.jgit.errors.RepositoryNotFoundException; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.lib.RepositoryCache; public class FilesystemDeleteHandler { - private static final FluentLogger log = FluentLogger.forEnclosingClass(); - private static final DateTimeFormatter FORMAT = - DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneId.of("UTC")); - - private final GitRepositoryManager repoManager; + private final RepositoryDelete repositoryDelete; private final DynamicSet<ProjectDeletedListener> deletedListeners; private final Configuration config; @Inject public FilesystemDeleteHandler( - GitRepositoryManager repoManager, + RepositoryDelete repositoryDelete, DynamicSet<ProjectDeletedListener> deletedListeners, Configuration config) { - this.repoManager = repoManager; + this.repositoryDelete = repositoryDelete; this.deletedListeners = deletedListeners; this.config = config; } public void delete(Project.NameKey project, boolean preserveGitRepository) throws IOException, RepositoryNotFoundException { - // Remove from the jgit cache - Repository repository = repoManager.openRepository(project); - cleanCache(repository); - if (!preserveGitRepository) { - Path repoPath = repository.getDirectory().toPath(); - String projectName = project.get(); - if (config.shouldArchiveDeletedRepos()) { - archiveGitRepository(projectName, repoPath); - } else { - deleteGitRepository(projectName, repoPath); - } - } - } - - private void cleanCache(Repository repository) { - repository.close(); - RepositoryCache.close(repository); - } - - private void archiveGitRepository(String projectName, Path repoPath) throws IOException { - Path basePath = getBasePath(repoPath, projectName); - Path renamedProjectDir = renameRepository(repoPath, basePath, projectName, "archived"); - try { - Path archive = getArchivePath(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); - } - } - - private Path getArchivePath(Path renamedProjectDir, Path basePath) { - Path configArchiveRepo = config.getArchiveFolder().toAbsolutePath(); - Path relativePath = basePath.relativize(renamedProjectDir); - return configArchiveRepo.resolve(relativePath); - } - - private void deleteGitRepository(String projectName, Path repoPath) 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); - } - } - - private Path getBasePath(Path repo, String projectName) { - Path projectPath = Paths.get(projectName); - return repo.getRoot() - .resolve(repo.subpath(0, repo.getNameCount() - projectPath.getNameCount())); - } - - private 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 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 void sendProjectDeletedEvent(String projectName) { - 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"); - } - } + repositoryDelete.execute( + project, + preserveGitRepository, + config.shouldArchiveDeletedRepos(), + Optional.ofNullable(config.getArchiveFolder()), + deletedListeners); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDelete.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDelete.java new file mode 100644 index 0000000..91bded5 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDelete.java
@@ -0,0 +1,216 @@ +// 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.Paths; +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); + } 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) 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); + } + } + + 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 = Paths.get(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"); + } + } + } +}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md index b72ee3a..2f0e7f6 100644 --- a/src/main/resources/Documentation/about.md +++ b/src/main/resources/Documentation/about.md
@@ -35,6 +35,28 @@ can be configured to listen to the project deletion event and to replicate project deletions. +Event after project deletion +----------------------------------- + +This plugin generates an event after project deletion. Format of +the event: + +=== Project Deleted + +Sent after project deletion. + +type:: "project-deleted" + +projectName:: Name of the deleted project + +eventCreatedOn:: Time in seconds since the UNIX epoch when this event was +created. + +*NOTE*: This event will be delivered only to the unrestricted listeners. +Unrestricted events listeners implement +`com.google.gerrit.server.events.EventListener` without performing any +permission checking. + Access ------
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProjectDeletedEventTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProjectDeletedEventTest.java new file mode 100644 index 0000000..07e3ee4 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProjectDeletedEventTest.java
@@ -0,0 +1,99 @@ +// Copyright (C) 2022 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.extensions.registration.DynamicItem; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.events.EventDispatcher; +import com.google.gerrit.server.project.ProjectResource; +import com.google.gerrit.server.project.ProjectState; +import com.google.inject.Provider; +import com.googlesource.gerrit.plugins.deleteproject.DeleteProject.Input; +import com.googlesource.gerrit.plugins.deleteproject.cache.CacheDeleteHandler; +import com.googlesource.gerrit.plugins.deleteproject.database.DatabaseDeleteHandler; +import com.googlesource.gerrit.plugins.deleteproject.fs.FilesystemDeleteHandler; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ProjectDeletedEventTest { + + private static final NameKey PROJECT_NAME_KEY = Project.nameKey("test-project"); + private static final String INSTANCE_ID = "test-instance-id"; + + @Mock private DatabaseDeleteHandler dbHandler; + @Mock private FilesystemDeleteHandler fsHandler; + @Mock private CacheDeleteHandler cacheHandler; + @Mock private Provider<CurrentUser> userProvider; + @Mock private DeleteLog deleteLog; + @Mock private DeletePreconditions preConditions; + @Mock private Configuration cfg; + @Mock private EventDispatcher dispatcher; + @Mock private DynamicItem<EventDispatcher> dispatcherProvider; + @Mock private HideProject hideProject; + @Mock private IdentifiedUser currentUser; + @Mock private ProjectState state; + @Captor private ArgumentCaptor<ProjectDeletedEvent> projectDeletedEventCaptor; + + private Project project = Project.builder(PROJECT_NAME_KEY).build(); + + private DeleteProject objectUnderTest; + + @Before + public void setup() throws Exception { + when(dispatcherProvider.get()).thenReturn(dispatcher); + when(userProvider.get()).thenReturn(currentUser); + when(state.getProject()).thenReturn(project); + objectUnderTest = + new DeleteProject( + dbHandler, + fsHandler, + cacheHandler, + userProvider, + deleteLog, + preConditions, + cfg, + hideProject, + dispatcherProvider, + INSTANCE_ID); + } + + @Test + public void shouldSendProjectDeletedEventAfterProjectDeletion() throws Exception { + Input input = new Input(); + input.force = false; + input.preserve = false; + objectUnderTest.doDelete(new ProjectResource(state, currentUser), input); + + verify(dispatcher).postEvent(eq(PROJECT_NAME_KEY), projectDeletedEventCaptor.capture()); + ProjectDeletedEvent event = projectDeletedEventCaptor.getValue(); + assertThat(event.instanceId).isEqualTo(INSTANCE_ID); + assertThat(event.getProjectNameKey()).isEqualTo(PROJECT_NAME_KEY); + assertThat(event.type).isEqualTo(ProjectDeletedEvent.TYPE); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java index 9cffd14..317de9b 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java
@@ -14,128 +14,54 @@ package com.googlesource.gerrit.plugins.deleteproject.fs; -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.Mockito.when; - import com.google.gerrit.entities.Project; 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.Configuration; -import java.io.IOException; -import java.nio.file.FileSystems; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.stream.Stream; -import org.eclipse.jgit.internal.storage.file.FileRepository; -import org.eclipse.jgit.lib.Repository; +import java.util.Optional; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class FilesystemDeleteHandlerTest { - @Mock private GitRepositoryManager repoManager; + @Mock private RepositoryDelete repositoryDelete; @Mock private ProjectDeletedListener projectDeleteListener; @Mock private Configuration config; @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); - private DynamicSet<ProjectDeletedListener> deletedListener; - private FilesystemDeleteHandler fsDeleteHandler; + private DynamicSet<ProjectDeletedListener> deletedListeners; private Path basePath; @Before public void setUp() throws Exception { - basePath = tempFolder.newFolder().toPath().resolve("base"); - deletedListener = new DynamicSet<>(); - deletedListener.add("", projectDeleteListener); + basePath = tempFolder.newFolder().toPath().resolve("archive"); + deletedListeners = new DynamicSet<>(); + deletedListeners.add("", projectDeleteListener); } @Test - public void shouldDeleteRepository() throws Exception { - String repoName = "testRepo"; - Repository repository = createRepository(repoName); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - when(config.shouldArchiveDeletedRepos()).thenReturn(false); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repository.getDirectory().exists()).isFalse(); - } + public void shouldExtractArchivingParamsFromConfig() throws Exception { + boolean doArchive = true; + Project.NameKey project = Project.NameKey.parse("testProject"); + boolean noPreserveGitRepository = false; - @Test - public void shouldDeleteEmptyParentFolders() throws Exception { - String repoName = "a/b/c"; - Repository repository = createRepository(repoName); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repository.getDirectory().exists()).isFalse(); - } + Mockito.when(config.shouldArchiveDeletedRepos()).thenReturn(doArchive); + Mockito.when(config.getArchiveFolder()).thenReturn(basePath); - @Test - public void shouldKeepCommonFolders() throws Exception { - String repoToDeleteName = "a/b/c/d"; - Repository repoToDelete = createRepository(repoToDeleteName); - - String repoToKeepName = "a/b/e"; - Repository repoToKeep = createRepository(repoToKeepName); - - Project.NameKey nameKey = Project.nameKey(repoToDeleteName); - when(repoManager.openRepository(nameKey)).thenReturn(repoToDelete); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repoToDelete.getDirectory().exists()).isFalse(); - assertThat(repoToKeep.getDirectory().exists()).isTrue(); - } - - @Test - public void shouldPreserveRepository() throws Exception { - String repoName = "preservedRepo"; - Repository repository = createRepository(repoName); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, true); - assertThat(repository.getDirectory().exists()).isTrue(); - } - - private FileRepository createRepository(String repoName) throws IOException { - Path repoPath = Files.createDirectories(basePath.resolve(repoName)); - Repository repository = new FileRepository(repoPath.toFile()); - repository.create(true); - return (FileRepository) repository; - } - - @Test - public void archiveRepository() throws Exception { - String repoName = "parent_project/p3"; - Repository repository = createRepository(repoName); - Path archiveFolder = basePath.resolve("test_archive"); - when(config.shouldArchiveDeletedRepos()).thenReturn(true); - when(config.getArchiveFolder()).thenReturn(archiveFolder); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repository.getDirectory().exists()).isFalse(); - String patternToVerify = archiveFolder.resolve(repoName).toString() + "*%archived%.git"; - assertThat(pathExistsWithPattern(archiveFolder, patternToVerify)).isTrue(); - } - - private boolean pathExistsWithPattern(Path archiveFolder, String patternToVerify) - throws IOException { - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + patternToVerify); - try (Stream<Path> stream = Files.walk(archiveFolder)) { - return stream.anyMatch(matcher::matches); - } + FilesystemDeleteHandler filesystemDeleteHandler = + new FilesystemDeleteHandler(repositoryDelete, deletedListeners, config); + filesystemDeleteHandler.delete(project, noPreserveGitRepository); + Mockito.verify(repositoryDelete) + .execute( + project, noPreserveGitRepository, doArchive, Optional.of(basePath), deletedListeners); } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDeleteTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDeleteTest.java new file mode 100644 index 0000000..512b8b5 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDeleteTest.java
@@ -0,0 +1,169 @@ +/* + * 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.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.events.ProjectDeletedListener; +import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.extensions.registration.RegistrationHandle; +import com.google.gerrit.server.git.GitRepositoryManager; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.Optional; +import java.util.stream.Stream; +import org.eclipse.jgit.internal.storage.file.FileRepository; +import org.eclipse.jgit.lib.Repository; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class RepositoryDeleteTest { + + private static final Optional<Path> NO_ARCHIVE_PATH = Optional.empty(); + + @Mock private GitRepositoryManager repoManager; + @Mock private ProjectDeletedListener projectDeleteListener; + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private DynamicSet<ProjectDeletedListener> deletedListeners; + private RegistrationHandle handle; + private RepositoryDelete repositoryDelete; + private Path basePath; + + @Before + public void setUp() throws Exception { + deletedListeners = new DynamicSet<>(); + handle = deletedListeners.add("testPlugin", projectDeleteListener); + basePath = tempFolder.newFolder().toPath().resolve("base"); + } + + @Test + public void shouldDeleteRepository() throws Exception { + String repoName = "testRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute(nameKey); + assertThat(repository.getDirectory().exists()).isFalse(); + } + + @Test + public void shouldDeleteEmptyParentFolders() throws Exception { + String repoName = "a/b/c"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute(nameKey); + assertThat(repository.getDirectory().exists()).isFalse(); + } + + @Test + public void shouldKeepCommonFolders() throws Exception { + String repoToDeleteName = "a/b/c/d"; + Repository repoToDelete = createRepository(repoToDeleteName); + + String repoToKeepName = "a/b/e"; + Repository repoToKeep = createRepository(repoToKeepName); + + Project.NameKey nameKey = Project.nameKey(repoToDeleteName); + when(repoManager.openRepository(nameKey)).thenReturn(repoToDelete); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute(nameKey); + assertThat(repoToDelete.getDirectory().exists()).isFalse(); + assertThat(repoToKeep.getDirectory().exists()).isTrue(); + } + + @Test + public void shouldPreserveRepository() throws Exception { + String repoName = "preservedRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute(nameKey, true, false, NO_ARCHIVE_PATH, deletedListeners); + assertThat(repository.getDirectory().exists()).isTrue(); + } + + private FileRepository createRepository(String repoName) throws IOException { + Path repoPath = Files.createDirectories(basePath.resolve(repoName)); + Repository repository = new FileRepository(repoPath.toFile()); + repository.create(true); + return (FileRepository) repository; + } + + @Test + public void archiveRepository() throws Exception { + String repoName = "parent_project/p3"; + Repository repository = createRepository(repoName); + Path archiveFolder = basePath.resolve("test_archive"); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute(nameKey, false, true, Optional.of(archiveFolder), deletedListeners); + assertThat(repository.getDirectory().exists()).isFalse(); + String patternToVerify = archiveFolder.resolve(repoName).toString() + "*%archived%.git"; + assertThat(pathExistsWithPattern(archiveFolder, patternToVerify)).isTrue(); + } + + @Test + public void shouldNotifyListenersOnSuccessfulRepoDeletion() throws Exception { + String repoName = "testRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute(nameKey, false, false, NO_ARCHIVE_PATH, deletedListeners); + Mockito.verify(projectDeleteListener).onProjectDeleted(any()); + } + + @Test + public void shouldNotNotifyListenersIfTheListenersSetIsEmpty() throws Exception { + String repoName = "testRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + handle.remove(); + repositoryDelete.execute(nameKey, false, false, NO_ARCHIVE_PATH, deletedListeners); + Mockito.verify(projectDeleteListener, never()).onProjectDeleted(any()); + } + + private boolean pathExistsWithPattern(Path archiveFolder, String patternToVerify) + throws IOException { + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + patternToVerify); + try (Stream<Path> stream = Files.walk(archiveFolder)) { + return stream.anyMatch(matcher::matches); + } + } +}