diff --git a/gradle/changelog/archive_cleanup.yaml b/gradle/changelog/archive_cleanup.yaml new file mode 100644 index 0000000000..902c3742b9 --- /dev/null +++ b/gradle/changelog/archive_cleanup.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Clean up old installation directories when installing plugins diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginArchiveCleaner.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginArchiveCleaner.java new file mode 100644 index 0000000000..a671f2d811 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginArchiveCleaner.java @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.util.IOUtil; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class PluginArchiveCleaner { + + private static final Logger LOG = LoggerFactory.getLogger(PluginArchiveCleaner.class); + private static final int MAX_ARCHIVE_COUNT = 5; + + public void cleanup(Path archivePath) throws IOException { + + try (Stream pathStream = Files.list(archivePath)) { + List pathList = pathStream + .filter(PluginArchiveCleaner::isAnInstalledPluginDirectory) + .sorted() + .collect(Collectors.toList()); + + for (int i = 0; i <= pathList.size() - MAX_ARCHIVE_COUNT; i++) { + LOG.debug("Delete old installation directory {}", pathList.get(i)); + IOUtil.deleteSilently(pathList.get(i).toFile()); + } + } + } + + private static boolean isAnInstalledPluginDirectory(Path path) { + return Files.isDirectory(path) && path.getFileName().toString().matches("\\d\\d\\d\\d-\\d\\d-\\d\\d"); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java index 6eabee1c8a..936e2f2c3e 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginProcessor.java @@ -26,6 +26,7 @@ package sonia.scm.plugin; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList.Builder; import com.google.common.collect.ImmutableSet; @@ -117,18 +118,21 @@ public final class PluginProcessor * @param classLoaderLifeCycle * @param pluginDirectory */ - public PluginProcessor(ClassLoaderLifeCycle classLoaderLifeCycle, Path pluginDirectory) - { + public PluginProcessor(ClassLoaderLifeCycle classLoaderLifeCycle, Path pluginDirectory){ + this(classLoaderLifeCycle, pluginDirectory, new PluginArchiveCleaner()); + } + + @VisibleForTesting + PluginProcessor(ClassLoaderLifeCycle classLoaderLifeCycle, Path pluginDirectory, PluginArchiveCleaner pluginArchiveCleaner) { this.classLoaderLifeCycle = classLoaderLifeCycle; this.pluginDirectory = pluginDirectory; + this.pluginArchiveCleaner = pluginArchiveCleaner; + this.installedRootDirectory = pluginDirectory.resolve(DIRECTORY_INSTALLED); this.installedDirectory = findInstalledDirectory(); - try - { + try { this.context = JAXBContext.newInstance(InstalledPluginDescriptor.class); - } - catch (JAXBException ex) - { + } catch (JAXBException ex) { throw new PluginLoadException("could not create jaxb context", ex); } } @@ -571,6 +575,8 @@ public final class PluginProcessor extracted.add(ExplodedSmp.create(directory.toPath())); } + pluginArchiveCleaner.cleanup(installedRootDirectory); + return extracted.build(); } @@ -583,7 +589,7 @@ public final class PluginProcessor private Path findInstalledDirectory() { Path directory = null; - Path installed = pluginDirectory.resolve(DIRECTORY_INSTALLED); + Path installed = installedRootDirectory; Path date = installed.resolve(createDate()); for (int i = 0; i < 999; i++) @@ -707,9 +713,12 @@ public final class PluginProcessor /** Field description */ private final JAXBContext context; + private final Path installedRootDirectory; + /** Field description */ private final Path installedDirectory; /** Field description */ private final Path pluginDirectory; + private final PluginArchiveCleaner pluginArchiveCleaner; } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginArchiveCleanerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginArchiveCleanerTest.java new file mode 100644 index 0000000000..2cc7efaae3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginArchiveCleanerTest.java @@ -0,0 +1,119 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PluginArchiveCleanerTest { + private final PluginArchiveCleaner cleaner = new PluginArchiveCleaner(); + + @Test + void shouldDoNothingBecauseThresholdIsNotReached(@TempDir Path tmp) throws IOException { + Files.createDirectories(tmp.resolve("2023-09-01")); + Files.createDirectories(tmp.resolve("2023-09-02")); + Files.createDirectories(tmp.resolve("2023-09-03")); + Files.createDirectories(tmp.resolve("2023-09-04")); + + cleaner.cleanup(tmp); + + assertThat(tmp.resolve("2023-09-01")).exists(); + assertThat(tmp.resolve("2023-09-02")).exists(); + assertThat(tmp.resolve("2023-09-03")).exists(); + assertThat(tmp.resolve("2023-09-04")).exists(); + } + + @Test + void shouldDeleteOldestFolder(@TempDir Path tmp) throws IOException { + Files.createDirectories(tmp.resolve("2023-09-01")); + Files.createDirectories(tmp.resolve("2023-09-02")); + Files.createDirectories(tmp.resolve("2023-09-03")); + Files.createDirectories(tmp.resolve("2023-09-04")); + Files.createDirectories(tmp.resolve("2023-09-05")); + Files.createDirectories(tmp.resolve("2023-09-06")); + + cleaner.cleanup(tmp); + + assertThat(tmp.resolve("2023-09-03")).exists(); + assertThat(tmp.resolve("2023-09-04")).exists(); + assertThat(tmp.resolve("2023-09-05")).exists(); + assertThat(tmp.resolve("2023-09-06")).exists(); + assertThat(tmp.resolve("2023-09-01")).doesNotExist(); + assertThat(tmp.resolve("2023-09-02")).doesNotExist(); + } + + @Test + void shouldNotDeleteFiles(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve("2023-09-01")); + Files.createFile(tmp.resolve("2023-09-02")); + Files.createFile(tmp.resolve("2023-09-03")); + Files.createFile(tmp.resolve("2023-09-04")); + Files.createFile(tmp.resolve("2023-09-05")); + Files.createFile(tmp.resolve("2023-09-06")); + + cleaner.cleanup(tmp); + + assertThat(tmp.resolve("2023-09-01")).exists(); + assertThat(tmp.resolve("2023-09-02")).exists(); + assertThat(tmp.resolve("2023-09-03")).exists(); + assertThat(tmp.resolve("2023-09-04")).exists(); + assertThat(tmp.resolve("2023-09-05")).exists(); + assertThat(tmp.resolve("2023-09-06")).exists(); + } + + @Test + void shouldNotDeleteNonMatchingFolder(@TempDir Path tmp) throws IOException { + Files.createDirectories(tmp.resolve("A")); + Files.createDirectories(tmp.resolve("B")); + Files.createDirectories(tmp.resolve("C")); + Files.createDirectories(tmp.resolve("D")); + Files.createDirectories(tmp.resolve("E")); + Files.createDirectories(tmp.resolve("F")); + + cleaner.cleanup(tmp); + + assertThat(tmp.resolve("A")).exists(); + assertThat(tmp.resolve("B")).exists(); + assertThat(tmp.resolve("C")).exists(); + assertThat(tmp.resolve("D")).exists(); + assertThat(tmp.resolve("E")).exists(); + assertThat(tmp.resolve("F")).exists(); + } + + @Test + void shouldDeleteNothingWithEmptyFolder(@TempDir Path tmp) throws IOException { + cleaner.cleanup(tmp); + + assertThat(tmp).exists(); + assertThat(tmp).isEmptyDirectory(); + } +} + diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java index f27aa19982..c378fbdf38 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginProcessorTest.java @@ -31,7 +31,11 @@ import com.google.common.collect.Iterables; import com.google.common.io.Resources; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.lifecycle.classloading.ClassLoaderLifeCycle; import javax.xml.bind.JAXB; @@ -53,6 +57,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; * * @author Sebastian Sdorra */ + +@ExtendWith(MockitoExtension.class) class PluginProcessorTest { private static final PluginResource PLUGIN_A = @@ -90,10 +96,13 @@ class PluginProcessorTest { private File pluginDirectory; private PluginProcessor processor; + @Mock + private PluginArchiveCleaner pluginArchiveCleaner; + @BeforeEach void setUp(@TempDir Path tempDirectoryPath) { pluginDirectory = tempDirectoryPath.toFile(); - processor = new PluginProcessor(ClassLoaderLifeCycle.create(), tempDirectoryPath); + processor = new PluginProcessor(ClassLoaderLifeCycle.create(), tempDirectoryPath, pluginArchiveCleaner); } @@ -171,6 +180,14 @@ class PluginProcessorTest { assertThat(plugin.getId()).isEqualTo(PLUGIN_A.id); } + @Test + void shouldCleanupAfterCollectingPlugins() throws IOException { + copySmp(PLUGIN_A); + + collectAndGetFirst(); + Mockito.verify(pluginArchiveCleaner).cleanup(pluginDirectory.toPath().resolve(".installed")); + } + @Test void shouldCollectPluginsAndDoNotFailOnNonPluginDirectories() throws IOException { assertThat(new File(pluginDirectory, "some-directory").mkdirs()).isTrue();