diff --git a/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java b/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java index b7e7b5e282..1c164a0d81 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java +++ b/scm-core/src/main/java/sonia/scm/plugin/AvailablePluginDescriptor.java @@ -1,5 +1,6 @@ package sonia.scm.plugin; +import java.util.Optional; import java.util.Set; /** @@ -10,11 +11,23 @@ public class AvailablePluginDescriptor implements PluginDescriptor { private final PluginInformation information; private final PluginCondition condition; private final Set dependencies; + private final String url; + private final String checksum; - public AvailablePluginDescriptor(PluginInformation information, PluginCondition condition, Set dependencies) { + public AvailablePluginDescriptor(PluginInformation information, PluginCondition condition, Set dependencies, String url, String checksum) { this.information = information; this.condition = condition; this.dependencies = dependencies; + this.url = url; + this.checksum = checksum; + } + + public String getUrl() { + return url; + } + + public Optional getChecksum() { + return Optional.ofNullable(checksum); } @Override diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index e465c8306e..77a13686c8 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -37,14 +37,20 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import com.google.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.NotFoundException; //~--- JDK imports ------------------------------------------------------------ import javax.inject.Inject; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; +import static sonia.scm.ContextEntry.ContextBuilder.entity; + /** * * @author Sebastian Sdorra @@ -52,13 +58,17 @@ import java.util.stream.Collectors; @Singleton public class DefaultPluginManager implements PluginManager { + private static final Logger LOG = LoggerFactory.getLogger(DefaultPluginManager.class); + private final PluginLoader loader; private final PluginCenter center; + private final PluginInstaller installer; @Inject - public DefaultPluginManager(PluginLoader loader, PluginCenter center) { + public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer) { this.loader = loader; this.center = center; + this.installer = installer; } @Override @@ -98,6 +108,18 @@ public class DefaultPluginManager implements PluginManager { @Override public void install(String name) { + if (getInstalled(name).isPresent()){ + LOG.info("plugin {} is already installed, skipping installation", name); + return; + } + AvailablePlugin plugin = getAvailable(name).orElseThrow(() -> NotFoundException.notFound(entity(AvailablePlugin.class, name))); + Set dependencies = plugin.getDescriptor().getDependencies(); + if (dependencies != null) { + for (String dependency: dependencies){ + install(dependency); + } + } + installer.install(plugin); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java new file mode 100644 index 0000000000..366558f3c9 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java @@ -0,0 +1,31 @@ +package sonia.scm.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; + +class PendingPluginInstallation { + + private static final Logger LOG = LoggerFactory.getLogger(PendingPluginInstallation.class); + + private final AvailablePlugin plugin; + private final File file; + + PendingPluginInstallation(AvailablePlugin plugin, File file) { + this.plugin = plugin; + this.file = file; + } + + public AvailablePlugin getPlugin() { + return plugin; + } + + void cancel() { + String name = plugin.getDescriptor().getInformation().getName(); + LOG.info("cancel installation of plugin {}", name); + if (!file.delete()) { + throw new PluginFailedToCancelInstallationException("failed to cancel installation of plugin " + name); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index afb8e739a0..1a18d696d5 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -3,6 +3,7 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -77,6 +78,8 @@ public final class PluginCenterDto implements Serializable { @XmlAccessorType(XmlAccessType.FIELD) @Getter + @NoArgsConstructor + @AllArgsConstructor static class Link { private String href; } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java index 972afa3099..1b84bca147 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -17,8 +17,9 @@ public abstract class PluginCenterDtoMapper { Set map(PluginCenterDto pluginCenterDto) { Set plugins = new HashSet<>(); for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) { + String url = plugin.getLinks().get("download").getHref(); AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( - map(plugin), map(plugin.getConditions()), plugin.getDependencies() + map(plugin), map(plugin.getConditions()), plugin.getDependencies(), url, plugin.getSha256() ); plugins.add(new AvailablePlugin(descriptor)); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java new file mode 100644 index 0000000000..1b04c0adf0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginChecksumMismatchException.java @@ -0,0 +1,7 @@ +package sonia.scm.plugin; + +public class PluginChecksumMismatchException extends PluginInstallException { + public PluginChecksumMismatchException(String message) { + super(message); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java new file mode 100644 index 0000000000..cb2a119f62 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDownloadException.java @@ -0,0 +1,7 @@ +package sonia.scm.plugin; + +public class PluginDownloadException extends PluginInstallException { + public PluginDownloadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java new file mode 100644 index 0000000000..2bb6db8125 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java @@ -0,0 +1,7 @@ +package sonia.scm.plugin; + +public class PluginFailedToCancelInstallationException extends RuntimeException { + public PluginFailedToCancelInstallationException(String message) { + super(message); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java new file mode 100644 index 0000000000..d7a840bdc1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstallException.java @@ -0,0 +1,12 @@ +package sonia.scm.plugin; + +public class PluginInstallException extends RuntimeException { + + public PluginInstallException(String message) { + super(message); + } + + public PluginInstallException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java new file mode 100644 index 0000000000..e512ea3212 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginInstaller.java @@ -0,0 +1,65 @@ +package sonia.scm.plugin; + +import com.google.common.hash.Hashing; +import com.google.common.io.ByteStreams; +import com.google.common.io.Files; +import sonia.scm.SCMContextProvider; +import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.util.IOUtil; + +import javax.inject.Inject; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Optional; + +class PluginInstaller { + + private final SCMContextProvider context; + private final AdvancedHttpClient client; + + @Inject + public PluginInstaller(SCMContextProvider context, AdvancedHttpClient client) { + this.context = context; + this.client = client; + } + + public PendingPluginInstallation install(AvailablePlugin plugin) { + File file = createFile(plugin); + try (InputStream input = download(plugin); OutputStream output = new FileOutputStream(file)) { + ByteStreams.copy(input, output); + + verifyChecksum(plugin, file); + + // TODO clean up in case of error + + return new PendingPluginInstallation(plugin, file); + } catch (IOException ex) { + throw new PluginDownloadException("failed to install plugin", ex); + } + } + + private void verifyChecksum(AvailablePlugin plugin, File file) throws IOException { + Optional checksum = plugin.getDescriptor().getChecksum(); + if (checksum.isPresent()) { + String calculatedChecksum = Files.hash(file, Hashing.sha256()).toString(); + if (!checksum.get().equalsIgnoreCase(calculatedChecksum)) { + throw new PluginChecksumMismatchException( + String.format("downloaded plugin checksum %s does not match expected %s", calculatedChecksum, checksum.get()) + ); + } + } + } + + private InputStream download(AvailablePlugin plugin) throws IOException { + return client.get(plugin.getDescriptor().getUrl()).request().contentAsStream(); + } + + private File createFile(AvailablePlugin plugin) { + File pluginDirectory = new File(context.getBaseDirectory(), "plugins"); + IOUtil.mkdirs(pluginDirectory); + return new File(pluginDirectory, plugin.getDescriptor().getInformation().getName() + ".smp"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index 54954c19d7..87a29a3210 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -21,7 +21,6 @@ import sonia.scm.plugin.AvailablePluginDescriptor; import sonia.scm.plugin.PluginCondition; import sonia.scm.plugin.PluginInformation; import sonia.scm.plugin.PluginManager; -import sonia.scm.plugin.PluginState; import sonia.scm.web.VndMediaType; import javax.inject.Provider; @@ -148,7 +147,9 @@ class AvailablePluginResourceTest { } private AvailablePlugin createPlugin(PluginInformation pluginInformation) { - AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor(pluginInformation, new PluginCondition(), Collections.emptySet()); + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( + pluginInformation, new PluginCondition(), Collections.emptySet(), "https://download.hitchhiker.com", null + ); return new AvailablePlugin(descriptor); } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index 22fe370915..c23f9394c3 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -2,7 +2,6 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import org.checkerframework.checker.nullness.Opt; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Answers; @@ -10,16 +9,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Arrays; import java.util.List; import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class DefaultPluginManagerTest { @@ -30,6 +24,9 @@ class DefaultPluginManagerTest { @Mock private PluginCenter center; + @Mock + private PluginInstaller installer; + @InjectMocks private DefaultPluginManager manager; @@ -118,6 +115,44 @@ class DefaultPluginManagerTest { assertThat(available).isEmpty(); } + @Test + void shouldInstallThePlugin() { + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + + manager.install("scm-git-plugin"); + + verify(installer).install(git); + } + + @Test + void shouldInstallDependingPlugins() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + manager.install("scm-review-plugin"); + + verify(installer).install(mail); + verify(installer).install(review); + } + + @Test + void shouldNotInstallAlreadyInstalledDependencies() { + AvailablePlugin review = createAvailable("scm-review-plugin"); + when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); + AvailablePlugin mail = createAvailable("scm-mail-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + + InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); + + manager.install("scm-review-plugin"); + + verify(installer).install(review); + } + private AvailablePlugin createAvailable(String name) { PluginInformation information = new PluginInformation(); information.setName(name); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java new file mode 100644 index 0000000000..0e4ff9cbce --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PendingPluginInstallationTest.java @@ -0,0 +1,46 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, TempDirectory.class}) +class PendingPluginInstallationTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private AvailablePlugin plugin; + + @Test + void shouldDeleteFileOnCancel(@TempDirectory.TempDir Path directory) throws IOException { + Path file = directory.resolve("file"); + Files.write(file, "42".getBytes()); + + when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin"); + + PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file.toFile()); + installation.cancel(); + + assertThat(file).doesNotExist(); + } + + @Test + void shouldThrowExceptionIfCancelFailed(@TempDirectory.TempDir Path directory) { + Path file = directory.resolve("file"); + when(plugin.getDescriptor().getInformation().getName()).thenReturn("scm-awesome-plugin"); + + PendingPluginInstallation installation = new PendingPluginInstallation(plugin, file.toFile()); + assertThrows(PluginFailedToCancelInstallationException.class, installation::cancel); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index 0c0b80ccfa..af4aac0e0d 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -1,5 +1,6 @@ package sonia.scm.plugin; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,13 +43,17 @@ class PluginCenterDtoMapperTest { "555000444", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), ImmutableSet.of("scm-review-plugin"), - new HashMap<>()); + ImmutableMap.of("download", new Link("http://download.hitchhiker.com")) + ); when(dto.getEmbedded().getPlugins()).thenReturn(Collections.singletonList(plugin)); AvailablePluginDescriptor descriptor = mapper.map(dto).iterator().next().getDescriptor(); PluginInformation information = descriptor.getInformation(); PluginCondition condition = descriptor.getCondition(); + assertThat(descriptor.getUrl()).isEqualTo("http://download.hitchhiker.com"); + assertThat(descriptor.getChecksum()).contains("555000444"); + assertThat(information.getAuthor()).isEqualTo(plugin.getAuthor()); assertThat(information.getCategory()).isEqualTo(plugin.getCategory()); assertThat(information.getVersion()).isEqualTo(plugin.getVersion()); @@ -72,7 +77,8 @@ class PluginCenterDtoMapperTest { "12345678aa", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), ImmutableSet.of("scm-review-plugin"), - new HashMap<>()); + ImmutableMap.of("download", new Link("http://download.hitchhiker.com/review")) + ); Plugin plugin2 = new Plugin( "scm-hitchhiker-plugin", @@ -85,7 +91,8 @@ class PluginCenterDtoMapperTest { "555000444", new Condition(Collections.singletonList("linux"), "amd64","2.0.0"), ImmutableSet.of("scm-review-plugin"), - new HashMap<>()); + ImmutableMap.of("download", new Link("http://download.hitchhiker.com/hitchhiker")) + ); when(dto.getEmbedded().getPlugins()).thenReturn(Arrays.asList(plugin1, plugin2)); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java new file mode 100644 index 0000000000..e209641222 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginInstallerTest.java @@ -0,0 +1,101 @@ +package sonia.scm.plugin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.net.ahc.AdvancedHttpClient; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.in; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, TempDirectory.class}) +class PluginInstallerTest { + + @Mock + private SCMContextProvider context; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private AdvancedHttpClient client; + + @InjectMocks + private PluginInstaller installer; + + private Path directory; + + @BeforeEach + void setUpContext(@TempDirectory.TempDir Path directory) { + this.directory = directory; + when(context.getBaseDirectory()).thenReturn(directory.toFile()); + } + + @Test + void shouldDownloadPlugin() throws IOException { + mockContent("42"); + + installer.install(createGitPlugin()); + + assertThat(directory.resolve("plugins").resolve("scm-git-plugin.smp")).hasContent("42"); + } + + @Test + void shouldReturnPendingPluginInstallation() throws IOException { + mockContent("42"); + AvailablePlugin gitPlugin = createGitPlugin(); + + PendingPluginInstallation pending = installer.install(gitPlugin); + + assertThat(pending).isNotNull(); + assertThat(pending.getPlugin()).isSameAs(gitPlugin); + } + + private void mockContent(String content) throws IOException { + when(client.get("https://download.hitchhiker.com").request().contentAsStream()) + .thenReturn(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + } + + private AvailablePlugin createGitPlugin() { + return createPlugin( + "scm-git-plugin", + "https://download.hitchhiker.com", + "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049" // 42 + ); + } + + @Test + void shouldThrowPluginDownloadException() throws IOException { + when(client.get("https://download.hitchhiker.com").request()).thenThrow(new IOException("failed to download")); + + assertThrows(PluginDownloadException.class, () -> installer.install(createGitPlugin())); + } + + @Test + void shouldThrowPluginChecksumMismatchException() throws IOException { + mockContent("21"); + + assertThrows(PluginChecksumMismatchException.class, () -> installer.install(createGitPlugin())); + } + + + private AvailablePlugin createPlugin(String name, String url, String checksum) { + PluginInformation information = new PluginInformation(); + information.setName(name); + AvailablePluginDescriptor descriptor = new AvailablePluginDescriptor( + information, null, Collections.emptySet(), url, checksum + ); + return new AvailablePlugin(descriptor); + } +}