diff --git a/scm-core/src/main/java/sonia/scm/boot/RestartEvent.java b/scm-core/src/main/java/sonia/scm/boot/RestartEvent.java index 244bc8f03c..9aab8d18ae 100644 --- a/scm-core/src/main/java/sonia/scm/boot/RestartEvent.java +++ b/scm-core/src/main/java/sonia/scm/boot/RestartEvent.java @@ -33,14 +33,16 @@ package sonia.scm.boot; //~--- non-JDK imports -------------------------------------------------------- -import sonia.scm.Stage; import sonia.scm.event.Event; /** * This event can be used to force a restart of the webapp context. The restart * event is useful during plugin development, because we don't have to restart - * the whole server, to see our changes. The restart event can only be used in - * stage {@link Stage#DEVELOPMENT}. + * the whole server, to see our changes. The restart event could also be used + * to install or upgrade plugins. + * + * But the restart event should be used carefully, because the whole context + * will be restarted and that process could take some time. * * @author Sebastian Sdorra * @since 2.0.0 diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java index bc8df673d0..1b7da51c4c 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryLocationResolver.java @@ -13,8 +13,27 @@ public abstract class RepositoryLocationResolver { return create(type); } - @FunctionalInterface public interface RepositoryLocationResolverInstance { + + /** + * Get the existing location for the repository. + * @param repositoryId The id of the repository. + * @throws IllegalStateException when there is no known location for the given repository. + */ T getLocation(String repositoryId); + + /** + * Create a new location for the new repository. + * @param repositoryId The id of the new repository. + * @throws IllegalStateException when there already is a location for the given repository registered. + */ + T createLocation(String repositoryId); + + /** + * Set the location of a new repository. + * @param repositoryId The id of the new repository. + * @throws IllegalStateException when there already is a location for the given repository registered. + */ + void setLocation(String repositoryId, T location); } } diff --git a/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java b/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java index 9c1fa590cc..8c3afbd90f 100644 --- a/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java +++ b/scm-core/src/main/java/sonia/scm/security/DefaultCipherHandler.java @@ -161,10 +161,17 @@ public class DefaultCipherHandler implements CipherHandler { * @return decrypted value */ public String decode(char[] plainKey, String value) { - String result = null; - + Base64.Decoder decoder = Base64.getUrlDecoder(); try { - byte[] encodedInput = Base64.getUrlDecoder().decode(value); + return decode(plainKey, value, decoder); + } catch (IllegalArgumentException e) { + return decode(plainKey, value, Base64.getDecoder()); + } + } + + private String decode(char[] plainKey, String value, Base64.Decoder decoder) { + try { + byte[] encodedInput = decoder.decode(value); byte[] salt = new byte[SALT_LENGTH]; byte[] encoded = new byte[encodedInput.length - SALT_LENGTH]; @@ -180,12 +187,10 @@ public class DefaultCipherHandler implements CipherHandler { byte[] decoded = cipher.doFinal(encoded); - result = new String(decoded, ENCODING); + return new String(decoded, ENCODING); } catch (IOException | GeneralSecurityException ex) { throw new CipherException("could not decode string", ex); } - - return result; } @Override diff --git a/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java b/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java index 8d95131ee6..3ea7737b4e 100644 --- a/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java +++ b/scm-core/src/main/java/sonia/scm/security/PermissionDescriptor.java @@ -100,7 +100,7 @@ public class PermissionDescriptor implements Serializable @Override public int hashCode() { - return value.hashCode(); + return value == null? -1: value.hashCode(); } /** diff --git a/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java b/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java index 972ed95a2d..51ff906b8f 100644 --- a/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java +++ b/scm-core/src/test/java/sonia/scm/util/ValidationUtilTest.java @@ -147,6 +147,10 @@ public class ValidationUtilTest public void testIsRepositoryNameValid() { String[] validPaths = { "scm", + "scm-", + "scm_", + "s_cm", + "s-cm", "s", "sc", ".hiddenrepo", @@ -206,7 +210,8 @@ public class ValidationUtilTest "a/..b", "scm/main", "scm/plugins/git-plugin", - "scm/plugins/git-plugin" + "_scm", + "-scm" }; for (String path : validPaths) { diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java index a1ce516069..81cf167071 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolver.java @@ -1,14 +1,14 @@ package sonia.scm.repository.xml; import sonia.scm.SCMContextProvider; +import sonia.scm.io.FileSystem; import sonia.scm.repository.BasicRepositoryLocationResolver; import sonia.scm.repository.InitialRepositoryLocationResolver; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.store.StoreConstants; import javax.inject.Inject; -import java.io.IOException; -import java.nio.file.Files; +import javax.inject.Singleton; import java.nio.file.Path; import java.time.Clock; import java.util.Map; @@ -28,12 +28,14 @@ import static sonia.scm.ContextEntry.ContextBuilder.entity; * * @since 2.0.0 */ +@Singleton public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocationResolver { public static final String STORE_NAME = "repository-paths"; private final SCMContextProvider contextProvider; private final InitialRepositoryLocationResolver initialRepositoryLocationResolver; + private final FileSystem fileSystem; private final PathDatabase pathDatabase; private final Map pathById; @@ -44,14 +46,15 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation private Long lastModified; @Inject - public PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver) { - this(contextProvider, initialRepositoryLocationResolver, Clock.systemUTC()); + public PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver, FileSystem fileSystem) { + this(contextProvider, initialRepositoryLocationResolver, fileSystem, Clock.systemUTC()); } - public PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver, Clock clock) { + PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver, FileSystem fileSystem, Clock clock) { super(Path.class); this.contextProvider = contextProvider; this.initialRepositoryLocationResolver = initialRepositoryLocationResolver; + this.fileSystem = fileSystem; this.pathById = new ConcurrentHashMap<>(); this.clock = clock; @@ -64,23 +67,43 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation @Override protected RepositoryLocationResolverInstance create(Class type) { - return repositoryId -> { - if (pathById.containsKey(repositoryId)) { - return (T) contextProvider.resolve(pathById.get(repositoryId)); - } else { - return (T) create(repositoryId); + return new RepositoryLocationResolverInstance() { + @Override + public T getLocation(String repositoryId) { + if (pathById.containsKey(repositoryId)) { + return (T) contextProvider.resolve(pathById.get(repositoryId)); + } else { + throw new IllegalStateException("location for repository " + repositoryId + " does not exist"); + } + } + + @Override + public T createLocation(String repositoryId) { + if (pathById.containsKey(repositoryId)) { + throw new IllegalStateException("location for repository " + repositoryId + " already exists"); + } else { + return (T) create(repositoryId); + } + } + + @Override + public void setLocation(String repositoryId, T location) { + if (pathById.containsKey(repositoryId)) { + throw new IllegalStateException("location for repository " + repositoryId + " already exists"); + } else { + PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, ((Path) location).toAbsolutePath()); + } } }; } Path create(String repositoryId) { Path path = initialRepositoryLocationResolver.getPath(repositoryId); - pathById.put(repositoryId, path); - writePathDatabase(); + setLocation(repositoryId, path); Path resolvedPath = contextProvider.resolve(path); try { - Files.createDirectories(resolvedPath); - } catch (IOException e) { + fileSystem.create(resolvedPath.toFile()); + } catch (Exception e) { throw new InternalRepositoryException(entity("Repository", repositoryId), "could not create directory for new repository", e); } return resolvedPath; @@ -138,4 +161,13 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation .resolve(StoreConstants.CONFIG_DIRECTORY_NAME) .resolve(STORE_NAME.concat(StoreConstants.FILE_EXTENSION)); } + + private void setLocation(String repositoryId, Path repositoryBasePath) { + pathById.put(repositoryId, repositoryBasePath); + writePathDatabase(); + } + + public void refresh() { + this.read(); + } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/SingleRepositoryUpdateProcessor.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/SingleRepositoryUpdateProcessor.java new file mode 100644 index 0000000000..eeb95f75b0 --- /dev/null +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/SingleRepositoryUpdateProcessor.java @@ -0,0 +1,15 @@ +package sonia.scm.repository.xml; + +import javax.inject.Inject; +import java.nio.file.Path; +import java.util.function.BiConsumer; + +public class SingleRepositoryUpdateProcessor { + + @Inject + private PathBasedRepositoryLocationResolver locationResolver; + + public void doUpdate(BiConsumer forEachRepository) { + locationResolver.forAllPaths(forEachRepository); + } +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java index f506793d1a..151e8f1281 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/XmlRepositoryDAO.java @@ -198,4 +198,11 @@ public class XmlRepositoryDAO implements RepositoryDAO { public Long getLastModified() { return repositoryLocationResolver.getLastModified(); } + + public void refresh() { + repositoryLocationResolver.refresh(); + byNamespaceAndName.clear(); + byId.clear(); + init(); + } } diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java index 5f758f124a..941775d6ea 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/PathBasedRepositoryLocationResolverTest.java @@ -11,6 +11,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import sonia.scm.SCMContextProvider; +import sonia.scm.io.DefaultFileSystem; +import sonia.scm.io.FileSystem; import sonia.scm.repository.InitialRepositoryLocationResolver; import java.io.IOException; @@ -41,6 +43,8 @@ class PathBasedRepositoryLocationResolverTest { @Mock private Clock clock; + private final FileSystem fileSystem = new DefaultFileSystem(); + private Path basePath; private PathBasedRepositoryLocationResolver resolver; @@ -57,7 +61,7 @@ class PathBasedRepositoryLocationResolverTest { @Test void shouldCreateInitialDirectory() { - Path path = resolver.forClass(Path.class).getLocation("newId"); + Path path = resolver.forClass(Path.class).createLocation("newId"); assertThat(path).isEqualTo(basePath.resolve("newId")); assertThat(path).isDirectory(); @@ -65,7 +69,7 @@ class PathBasedRepositoryLocationResolverTest { @Test void shouldPersistInitialDirectory() { - resolver.forClass(Path.class).getLocation("newId"); + resolver.forClass(Path.class).createLocation("newId"); String content = getXmlFileContent(); @@ -78,7 +82,7 @@ class PathBasedRepositoryLocationResolverTest { long now = CREATION_TIME + 100; when(clock.millis()).thenReturn(now); - resolver.forClass(Path.class).getLocation("newId"); + resolver.forClass(Path.class).createLocation("newId"); assertThat(resolver.getCreationTime()).isEqualTo(CREATION_TIME); @@ -91,7 +95,7 @@ class PathBasedRepositoryLocationResolverTest { long now = CREATION_TIME + 100; when(clock.millis()).thenReturn(now); - resolver.forClass(Path.class).getLocation("newId"); + resolver.forClass(Path.class).createLocation("newId"); assertThat(resolver.getCreationTime()).isEqualTo(CREATION_TIME); assertThat(resolver.getLastModified()).isEqualTo(now); @@ -108,8 +112,8 @@ class PathBasedRepositoryLocationResolverTest { @BeforeEach void createExistingDatabase() { - resolver.forClass(Path.class).getLocation("existingId_1"); - resolver.forClass(Path.class).getLocation("existingId_2"); + resolver.forClass(Path.class).createLocation("existingId_1"); + resolver.forClass(Path.class).createLocation("existingId_2"); resolverWithExistingData = createResolver(); } @@ -159,7 +163,7 @@ class PathBasedRepositoryLocationResolverTest { } private PathBasedRepositoryLocationResolver createResolver() { - return new PathBasedRepositoryLocationResolver(contextProvider, initialRepositoryLocationResolver, clock); + return new PathBasedRepositoryLocationResolver(contextProvider, initialRepositoryLocationResolver, fileSystem, clock); } private String content(Path storePath) { diff --git a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java index 5b9a00aec8..bdf28310e1 100644 --- a/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java +++ b/scm-dao-xml/src/test/java/sonia/scm/repository/xml/XmlRepositoryDAOTest.java @@ -8,8 +8,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junitpioneer.jupiter.TempDirectory; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.jupiter.MockitoExtension; @@ -19,6 +17,7 @@ import sonia.scm.io.DefaultFileSystem; import sonia.scm.io.FileSystem; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryLocationResolver; import sonia.scm.repository.RepositoryPermission; import java.io.IOException; @@ -32,7 +31,9 @@ import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -47,16 +48,29 @@ class XmlRepositoryDAOTest { @Mock private PathBasedRepositoryLocationResolver locationResolver; - @Captor - private ArgumentCaptor> forAllCaptor; - private FileSystem fileSystem = new DefaultFileSystem(); private XmlRepositoryDAO dao; @BeforeEach void createDAO(@TempDirectory.TempDir Path basePath) { - when(locationResolver.create(Path.class)).thenReturn(locationResolver::create); + when(locationResolver.create(Path.class)).thenReturn( + new RepositoryLocationResolver.RepositoryLocationResolverInstance() { + @Override + public Path getLocation(String repositoryId) { + return locationResolver.create(repositoryId); + } + + @Override + public Path createLocation(String repositoryId) { + return locationResolver.create(repositoryId); + } + + @Override + public void setLocation(String repositoryId, Path location) { + } + } + ); when(locationResolver.create(anyString())).thenAnswer(invocation -> createMockedRepoPath(basePath, invocation)); when(locationResolver.remove(anyString())).thenAnswer(invocation -> basePath.resolve(invocation.getArgument(0).toString())); } @@ -268,43 +282,80 @@ class XmlRepositoryDAOTest { verify(locationResolver).updateModificationDate(); } - } - @Test - void shouldReadExistingRepositoriesFromPathDatabase(@TempDirectory.TempDir Path basePath) throws IOException { - doNothing().when(locationResolver).forAllPaths(forAllCaptor.capture()); - XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem); + private String getXmlFileContent(String id) { + Path storePath = metadataFile(id); - Path repositoryPath = basePath.resolve("existing"); - Files.createDirectories(repositoryPath); - URL metadataUrl = Resources.getResource("sonia/scm/store/repositoryDaoMetadata.xml"); - Files.copy(metadataUrl.openStream(), repositoryPath.resolve("metadata.xml")); + assertThat(storePath).isRegularFile(); + return content(storePath); + } - forAllCaptor.getValue().accept("existing", repositoryPath); + private Path metadataFile(String id) { + return locationResolver.create(id).resolve("metadata.xml"); + } - assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue(); - } - - private String getXmlFileContent(String id) { - Path storePath = metadataFile(id); - - assertThat(storePath).isRegularFile(); - return content(storePath); - } - - private Path metadataFile(String id) { - return locationResolver.create(id).resolve("metadata.xml"); - } - - private String content(Path storePath) { - try { - return new String(Files.readAllBytes(storePath), Charsets.UTF_8); - } catch (IOException e) { - throw new RuntimeException(e); + private String content(Path storePath) { + try { + return new String(Files.readAllBytes(storePath), Charsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException(e); + } } } - private static Repository createRepository(String id) { + @Nested + class WithExistingRepositories { + + private Path repositoryPath; + + @BeforeEach + void createMetadataFileForRepository(@TempDirectory.TempDir Path basePath) throws IOException { + repositoryPath = basePath.resolve("existing"); + + Files.createDirectories(repositoryPath); + URL metadataUrl = Resources.getResource("sonia/scm/store/repositoryDaoMetadata.xml"); + Files.copy(metadataUrl.openStream(), repositoryPath.resolve("metadata.xml")); + } + + @Test + void shouldReadExistingRepositoriesFromPathDatabase() { + // given + mockExistingPath(); + + // when + XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem); + + // then + assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue(); + } + + @Test + void shouldRefreshWithExistingRepositoriesFromPathDatabase() { + // given + doNothing().when(locationResolver).forAllPaths(any()); + XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem); + + mockExistingPath(); + + // when + dao.refresh(); + + // then + verify(locationResolver).refresh(); + assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue(); + } + + private void mockExistingPath() { + doAnswer( + invocation -> { + ((BiConsumer) invocation.getArgument(0)).accept("existing", repositoryPath); + return null; + } + ).when(locationResolver).forAllPaths(any()); + } + } + + private Repository createRepository(String id) { return new Repository(id, "xml", "space", id); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsStoreRemoveListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsStoreRemoveListener.java deleted file mode 100644 index 18fb333c74..0000000000 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsStoreRemoveListener.java +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.web.lfs; - -import com.github.legman.Subscribe; -import com.google.inject.Inject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import sonia.scm.EagerSingleton; -import sonia.scm.HandlerEventType; -import sonia.scm.plugin.Extension; -import sonia.scm.repository.GitRepositoryHandler; -import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryEvent; -import sonia.scm.store.Blob; -import sonia.scm.store.BlobStore; - -/** - * Listener which removes all lfs objects from a blob store, whenever its corresponding git repository gets deleted. - * - * @author Sebastian Sdorra - * @since 1.54 - */ -@Extension -@EagerSingleton -public class LfsStoreRemoveListener { - - private static final Logger LOG = LoggerFactory.getLogger(LfsBlobStoreFactory.class); - - private final LfsBlobStoreFactory lfsBlobStoreFactory; - - @Inject - public LfsStoreRemoveListener(LfsBlobStoreFactory lfsBlobStoreFactory) { - this.lfsBlobStoreFactory = lfsBlobStoreFactory; - } - - /** - * Remove all object from the blob store, if the event is an delete event and the repository is a git repository. - * - * @param event repository event - */ - @Subscribe - public void handleRepositoryEvent(RepositoryEvent event) { - if ( isDeleteEvent(event) && isGitRepositoryEvent(event) ) { - removeLfsStore(event.getItem()); - } - } - - private boolean isDeleteEvent(RepositoryEvent event) { - return HandlerEventType.DELETE == event.getEventType(); - } - - private boolean isGitRepositoryEvent(RepositoryEvent event) { - return event.getItem() != null - && event.getItem().getType().equals(GitRepositoryHandler.TYPE_NAME); - } - - private void removeLfsStore(Repository repository) { - LOG.debug("remove all blobs from store, because corresponding git repository {} was removed", repository.getName()); - BlobStore blobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); - for ( Blob blob : blobStore.getAll() ) { - LOG.trace("remove blob {}, because repository {} was removed", blob.getId(), repository.getName()); - blobStore.remove(blob); - } - } - -} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsStoreRemoveListenerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsStoreRemoveListenerTest.java deleted file mode 100644 index 34a1a3f4d5..0000000000 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/lfs/LfsStoreRemoveListenerTest.java +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - -package sonia.scm.web.lfs; - -import com.google.common.collect.Lists; -import java.util.List; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import static org.mockito.Mockito.*; -import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.HandlerEventType; -import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryEvent; -import sonia.scm.repository.RepositoryTestData; -import sonia.scm.store.Blob; -import sonia.scm.store.BlobStore; - -/** - * Unit tests for {@link LfsStoreRemoveListener}. - * - * @author Sebastian Sdorra - */ -@RunWith(MockitoJUnitRunner.class) -public class LfsStoreRemoveListenerTest { - - @Mock - private LfsBlobStoreFactory lfsBlobStoreFactory; - - @Mock - private BlobStore blobStore; - - @InjectMocks - private LfsStoreRemoveListener lfsStoreRemoveListener; - - @Test - public void testHandleRepositoryEventWithNonDeleteEvents() { - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.BEFORE_CREATE)); - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.CREATE)); - - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.BEFORE_MODIFY)); - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.MODIFY)); - - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.BEFORE_DELETE)); - - verifyZeroInteractions(lfsBlobStoreFactory); - } - - @Test - public void testHandleRepositoryEventWithNonGitRepositories() { - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.DELETE, "svn")); - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.DELETE, "hg")); - lfsStoreRemoveListener.handleRepositoryEvent(event(HandlerEventType.DELETE, "dummy")); - - verifyZeroInteractions(lfsBlobStoreFactory); - } - - @Test - public void testHandleRepositoryEvent() { - Repository heartOfGold = RepositoryTestData.createHeartOfGold("git"); - - when(lfsBlobStoreFactory.getLfsBlobStore(heartOfGold)).thenReturn(blobStore); - Blob blobA = mockBlob("a"); - Blob blobB = mockBlob("b"); - List blobs = Lists.newArrayList(blobA, blobB); - when(blobStore.getAll()).thenReturn(blobs); - - - lfsStoreRemoveListener.handleRepositoryEvent(new RepositoryEvent(HandlerEventType.DELETE, heartOfGold)); - verify(blobStore).getAll(); - verify(blobStore).remove(blobA); - verify(blobStore).remove(blobB); - - verifyNoMoreInteractions(blobStore); - } - - private Blob mockBlob(String id) { - Blob blob = mock(Blob.class); - when(blob.getId()).thenReturn(id); - return blob; - } - - private RepositoryEvent event(HandlerEventType eventType) { - return event(eventType, "git"); - } - - private RepositoryEvent event(HandlerEventType eventType, String repositoryType) { - return new RepositoryEvent(eventType, RepositoryTestData.create42Puzzle(repositoryType)); - } - -} diff --git a/scm-test/src/main/java/sonia/scm/TempDirRepositoryLocationResolver.java b/scm-test/src/main/java/sonia/scm/TempDirRepositoryLocationResolver.java index 1dfc22e15a..acffe6c769 100644 --- a/scm-test/src/main/java/sonia/scm/TempDirRepositoryLocationResolver.java +++ b/scm-test/src/main/java/sonia/scm/TempDirRepositoryLocationResolver.java @@ -1,7 +1,6 @@ package sonia.scm; import sonia.scm.repository.BasicRepositoryLocationResolver; -import sonia.scm.repository.RepositoryLocationResolver; import java.io.File; import java.nio.file.Path; @@ -16,6 +15,21 @@ public class TempDirRepositoryLocationResolver extends BasicRepositoryLocationRe @Override protected RepositoryLocationResolverInstance create(Class type) { - return repositoryId -> (T) tempDirectory.toPath(); + return new RepositoryLocationResolverInstance() { + @Override + public T getLocation(String repositoryId) { + return (T) tempDirectory.toPath(); + } + + @Override + public T createLocation(String repositoryId) { + return (T) tempDirectory.toPath(); + } + + @Override + public void setLocation(String repositoryId, T location) { + throw new UnsupportedOperationException("not implemented for tests"); + } + }; } } diff --git a/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java b/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java index 063681bbc4..615169b58c 100644 --- a/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java +++ b/scm-test/src/main/java/sonia/scm/repository/SimpleRepositoryHandlerTestBase.java @@ -34,6 +34,7 @@ package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- import org.junit.Test; +import org.mockito.stubbing.Answer; import sonia.scm.AbstractTestBase; import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.store.InMemoryConfigurationStoreFactory; @@ -82,11 +83,12 @@ public abstract class SimpleRepositoryHandlerTestBase extends AbstractTestBase { RepositoryLocationResolver.RepositoryLocationResolverInstance instanceMock = mock(RepositoryLocationResolver.RepositoryLocationResolverInstance.class); when(locationResolver.create(any())).thenReturn(instanceMock); when(locationResolver.supportsLocationType(any())).thenReturn(true); - - when(instanceMock.getLocation(anyString())).then(ic -> { + Answer pathAnswer = ic -> { String id = ic.getArgument(0); return baseDirectory.toPath().resolve(id); - }); + }; + when(instanceMock.getLocation(anyString())).then(pathAnswer); + when(instanceMock.createLocation(anyString())).then(pathAnswer); handler = createRepositoryHandler(storeFactory, locationResolver, baseDirectory); } diff --git a/scm-ui-components/packages/ui-components/src/Icon.js b/scm-ui-components/packages/ui-components/src/Icon.js new file mode 100644 index 0000000000..b3b9a9b4c2 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/Icon.js @@ -0,0 +1,25 @@ +//@flow +import React from "react"; +import classNames from "classnames"; + +type Props = { + title?: string, + name: string +} + +export default class Icon extends React.Component { + + render() { + const { title, name } = this.props; + if(title) { + return ( + + ); + } + return ( + + ); + } + +} + diff --git a/scm-ui-components/packages/ui-components/src/forms/Radio.js b/scm-ui-components/packages/ui-components/src/forms/Radio.js index 6460e67070..7e61b25ec0 100644 --- a/scm-ui-components/packages/ui-components/src/forms/Radio.js +++ b/scm-ui-components/packages/ui-components/src/forms/Radio.js @@ -22,19 +22,18 @@ class Radio extends React.Component { render() { return ( - - + ); } } diff --git a/scm-ui-components/packages/ui-components/src/index.js b/scm-ui-components/packages/ui-components/src/index.js index 954b0b0955..82780c94eb 100644 --- a/scm-ui-components/packages/ui-components/src/index.js +++ b/scm-ui-components/packages/ui-components/src/index.js @@ -9,6 +9,7 @@ export { validation, urls, repositories }; export { default as DateFromNow } from "./DateFromNow.js"; export { default as ErrorNotification } from "./ErrorNotification.js"; export { default as ErrorPage } from "./ErrorPage.js"; +export { default as Icon } from "./Icon.js"; export { default as Image } from "./Image.js"; export { default as Loading } from "./Loading.js"; export { default as Logo } from "./Logo.js"; diff --git a/scm-ui-components/packages/ui-components/src/navigation/NavLink.js b/scm-ui-components/packages/ui-components/src/navigation/NavLink.js index 98c3138a8f..e1236371a8 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/NavLink.js +++ b/scm-ui-components/packages/ui-components/src/navigation/NavLink.js @@ -1,5 +1,6 @@ //@flow import * as React from "react"; +import classNames from "classnames"; import {Link, Route} from "react-router-dom"; // TODO mostly copy of PrimaryNavigationLink @@ -28,7 +29,7 @@ class NavLink extends React.Component { let showIcon = null; if (icon) { - showIcon = (<>{" "}); + showIcon = (<>{" "}); } return ( diff --git a/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js b/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js index 0a6612a173..cdf92d068d 100644 --- a/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js +++ b/scm-ui-components/packages/ui-components/src/navigation/SubNavigation.js @@ -1,6 +1,7 @@ //@flow import * as React from "react"; import { Link, Route } from "react-router-dom"; +import classNames from "classnames"; type Props = { to: string, @@ -37,7 +38,7 @@ class SubNavigation extends React.Component { return (
  • - {label} + {label} {children}
  • diff --git a/scm-ui/public/locales/de/groups.json b/scm-ui/public/locales/de/groups.json index bb8eda1cf2..f1c5f295b1 100644 --- a/scm-ui/public/locales/de/groups.json +++ b/scm-ui/public/locales/de/groups.json @@ -6,6 +6,7 @@ "lastModified": "Zuletzt bearbeitet", "type": "Typ", "external": "Extern", + "internal": "Intern", "members": "Mitglieder" }, "groups": { diff --git a/scm-ui/public/locales/de/users.json b/scm-ui/public/locales/de/users.json index 37416c1e4f..25b6858c8b 100644 --- a/scm-ui/public/locales/de/users.json +++ b/scm-ui/public/locales/de/users.json @@ -5,6 +5,7 @@ "mail": "E-Mail", "password": "Passwort", "active": "Aktiv", + "inactive": "Inaktiv", "type": "Typ", "creationDate": "Erstellt", "lastModified": "Zuletzt bearbeitet" diff --git a/scm-ui/public/locales/en/groups.json b/scm-ui/public/locales/en/groups.json index aff350a458..f14347ed30 100644 --- a/scm-ui/public/locales/en/groups.json +++ b/scm-ui/public/locales/en/groups.json @@ -6,6 +6,7 @@ "lastModified": "Last Modified", "type": "Type", "external": "External", + "internal": "Internal", "members": "Members" }, "groups": { diff --git a/scm-ui/public/locales/en/users.json b/scm-ui/public/locales/en/users.json index d188f6221b..b492923a3f 100644 --- a/scm-ui/public/locales/en/users.json +++ b/scm-ui/public/locales/en/users.json @@ -5,6 +5,7 @@ "mail": "E-Mail", "password": "Password", "active": "Active", + "inactive": "Inactive", "type": "Type", "creationDate": "Creation Date", "lastModified": "Last Modified" diff --git a/scm-ui/src/groups/components/GroupForm.js b/scm-ui/src/groups/components/GroupForm.js index baa68de8ef..04a5e6f35f 100644 --- a/scm-ui/src/groups/components/GroupForm.js +++ b/scm-ui/src/groups/components/GroupForm.js @@ -123,7 +123,7 @@ class GroupForm extends React.Component { ); }; - isExistingGroup = () => !! this.props.group; + isExistingGroup = () => !!this.props.group; render() { const { loading, t } = this.props; diff --git a/scm-ui/src/groups/components/table/GroupRow.js b/scm-ui/src/groups/components/table/GroupRow.js index 20bc90279e..509fe09ce4 100644 --- a/scm-ui/src/groups/components/table/GroupRow.js +++ b/scm-ui/src/groups/components/table/GroupRow.js @@ -1,29 +1,38 @@ // @flow import React from "react"; +import { translate } from "react-i18next"; import { Link } from "react-router-dom"; import type { Group } from "@scm-manager/ui-types"; -import { Checkbox } from "@scm-manager/ui-components"; +import { Icon } from "@scm-manager/ui-components"; type Props = { - group: Group + group: Group, + + // context props + t: string => string }; -export default class GroupRow extends React.Component { +class GroupRow extends React.Component { renderLink(to: string, label: string) { return {label}; } render() { - const { group } = this.props; + const { group, t } = this.props; const to = `/group/${group.name}`; + const iconType = group.external ? ( + + ) : ( + + ); + return ( - {this.renderLink(to, group.name)} + {iconType} {this.renderLink(to, group.name)} {group.description} - - - ); } } + +export default translate("groups")(GroupRow); diff --git a/scm-ui/src/groups/components/table/GroupTable.js b/scm-ui/src/groups/components/table/GroupTable.js index 85bcb813fc..c7b59b40a7 100644 --- a/scm-ui/src/groups/components/table/GroupTable.js +++ b/scm-ui/src/groups/components/table/GroupTable.js @@ -18,7 +18,6 @@ class GroupTable extends React.Component { {t("group.name")} {t("group.description")} - {t("group.external")} diff --git a/scm-ui/src/repos/permissions/containers/SinglePermission.js b/scm-ui/src/repos/permissions/containers/SinglePermission.js index bdb96de0bd..37f3436af3 100644 --- a/scm-ui/src/repos/permissions/containers/SinglePermission.js +++ b/scm-ui/src/repos/permissions/containers/SinglePermission.js @@ -11,7 +11,7 @@ import { } from "../modules/permissions"; import { connect } from "react-redux"; import type { History } from "history"; -import { Button } from "@scm-manager/ui-components"; +import { Button, Icon } from "@scm-manager/ui-components"; import DeletePermissionButton from "../components/buttons/DeletePermissionButton"; import RoleSelector from "../components/RoleSelector"; import AdvancedPermissionsDialog from "./AdvancedPermissionsDialog"; @@ -49,9 +49,6 @@ type State = { }; const styles = { - iconColor: { - color: "#9a9a9a" - }, centerMiddle: { display: "table-cell", verticalAlign: "middle !important" @@ -148,15 +145,9 @@ class SinglePermission extends React.Component { const iconType = permission && permission.groupPermission ? ( - + ) : ( - + ); return ( @@ -171,7 +162,7 @@ class SinglePermission extends React.Component { action={this.handleDetailedPermissionsPressed} /> - + { return ( this.props.user.displayName === user.displayName && this.props.user.mail === user.mail && - this.props.user.admin === user.admin && this.props.user.active === user.active ); } else { diff --git a/scm-ui/src/users/components/table/UserRow.js b/scm-ui/src/users/components/table/UserRow.js index 9cb55295fb..03c7f1ebaa 100644 --- a/scm-ui/src/users/components/table/UserRow.js +++ b/scm-ui/src/users/components/table/UserRow.js @@ -1,31 +1,43 @@ // @flow import React from "react"; +import { translate } from "react-i18next"; import { Link } from "react-router-dom"; import type { User } from "@scm-manager/ui-types"; +import { Icon } from "@scm-manager/ui-components"; type Props = { - user: User + user: User, + + // context props + t: string => string }; -export default class UserRow extends React.Component { +class UserRow extends React.Component { renderLink(to: string, label: string) { return {label}; } render() { - const { user } = this.props; + const { user, t } = this.props; const to = `/user/${user.name}`; + const iconType = user.active ? ( + + ) : ( + + ); + return ( - - {this.renderLink(to, user.name)} - {this.renderLink(to, user.displayName)} + + {iconType} {this.renderLink(to, user.name)} + + {this.renderLink(to, user.displayName)} + {user.mail} - - - ); } } + +export default translate("users")(UserRow); diff --git a/scm-ui/src/users/components/table/UserTable.js b/scm-ui/src/users/components/table/UserTable.js index 8c012f11f4..ab29dca9e7 100644 --- a/scm-ui/src/users/components/table/UserTable.js +++ b/scm-ui/src/users/components/table/UserTable.js @@ -19,7 +19,6 @@ class UserTable extends React.Component { {t("user.name")} {t("user.displayName")} {t("user.mail")} - {t("user.active")} diff --git a/scm-ui/styles/scm.scss b/scm-ui/styles/scm.scss index 09a4eb5c27..05bd1c2949 100644 --- a/scm-ui/styles/scm.scss +++ b/scm-ui/styles/scm.scss @@ -219,16 +219,23 @@ ul.is-separated { // card tables .card-table { border-collapse: separate; - border-spacing: 0px 5px; + border-spacing: 0 5px; tr { a { color: #363636; } + &.border-is-green td:first-child { + border-left-color: $green; + } + &.border-is-yellow td:first-child { + border-left-color: $yellow; + } &:hover { td { background-color: whitesmoke; - &:nth-child(4) { + + &.is-darker { background-color: #e1e1e1; } } @@ -238,13 +245,14 @@ ul.is-separated { } } td { - border-bottom: 1px solid whitesmoke; - background-color: #fafafa; padding: 1em 1.25em; + background-color: #fafafa; + border-bottom: 1px solid whitesmoke; + &:first-child { - border-left: 3px solid $mint; + border-left: 3px solid $grey; } - &:nth-child(4) { + &.is-darker { background-color: whitesmoke; } } @@ -318,6 +326,10 @@ form .field:not(.is-grouped) { } } +.is-icon { + color: $grey-light; +} + // label with help-icon compensation .label-icon-spacing { margin-top: 30px; diff --git a/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java b/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java index 5cb3f5dfa5..cc58d34ef3 100644 --- a/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java @@ -58,6 +58,8 @@ import sonia.scm.util.IOUtil; import javax.inject.Inject; import javax.servlet.ServletContext; import javax.servlet.ServletContextEvent; +import java.io.Closeable; +import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Set; @@ -73,11 +75,11 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList * the logger for ScmContextListener */ private static final Logger LOG = LoggerFactory.getLogger(ScmContextListener.class); - + private final ClassLoader parent; private final Set plugins; private Injector injector; - + public interface Factory { ScmContextListener create(ClassLoader parent, Set plugins); } @@ -99,23 +101,16 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList super.contextInitialized(servletContextEvent); afterInjectorCreation(servletContextEvent); } - + private void beforeInjectorCreation() { } private boolean hasStartupErrors() { return SCMContext.getContext().getStartupError() != null; } - + @Override protected List getModules(ServletContext context) { - if (hasStartupErrors()) { - return getErrorModules(); - } - return getDefaultModules(context); - } - - private List getDefaultModules(ServletContext context) { DefaultPluginLoader pluginLoader = new DefaultPluginLoader(context, parent, plugins); ClassOverrides overrides = ClassOverrides.findOverrides(pluginLoader.getUberClassLoader()); @@ -130,15 +125,15 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList ); appendModules(pluginLoader.getExtensionProcessor(), moduleList); moduleList.addAll(overrides.getModules()); - + if (SCMContext.getContext().getStage() == Stage.DEVELOPMENT){ moduleList.add(new DebugModule()); } moduleList.add(new MapperModule()); - return moduleList; + return moduleList; } - + private void appendModules(ExtensionProcessor ep, List moduleList) { for (Class module : ep.byExtensionPoint(Module.class)) { try { @@ -149,31 +144,27 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList } } } - - private List getErrorModules() { - return Collections.singletonList(new ScmErrorModule()); - } @Override protected void withInjector(Injector injector) { this.injector = injector; } - + private void afterInjectorCreation(ServletContextEvent event) { if (injector != null && !hasStartupErrors()) { bindEagerSingletons(); initializeServletContextListeners(event); - } + } } - + private void bindEagerSingletons() { injector.getInstance(EagerSingletonModule.class).initialize(injector); } - + private void initializeServletContextListeners(ServletContextEvent event) { injector.getInstance(ServletContextListenerHolder.class).contextInitialized(event); } - + @Override public void contextDestroyed(ServletContextEvent servletContextEvent) { @@ -183,8 +174,20 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList } super.contextDestroyed(servletContextEvent); + + for (PluginWrapper plugin : getPlugins()) { + ClassLoader pcl = plugin.getClassLoader(); + + if (pcl instanceof Closeable) { + try { + ((Closeable) pcl).close(); + } catch (IOException ex) { + LOG.warn("could not close plugin classloader", ex); + } + } + } } - + private void closeCloseables() { // close Scheduler IOUtil.close(injector.getInstance(Scheduler.class)); @@ -205,6 +208,4 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList private void destroyServletContextListeners(ServletContextEvent event) { injector.getInstance(ServletContextListenerHolder.class).contextDestroyed(event); } - - } diff --git a/scm-webapp/src/main/java/sonia/scm/ScmErrorModule.java b/scm-webapp/src/main/java/sonia/scm/ScmErrorModule.java deleted file mode 100644 index 56ae8527f2..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/ScmErrorModule.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.inject.multibindings.Multibinder; -import com.google.inject.servlet.ServletModule; - -import sonia.scm.template.ErrorServlet; -import sonia.scm.template.MustacheTemplateEngine; -import sonia.scm.template.TemplateEngine; -import sonia.scm.template.TemplateEngineFactory; - -/** - * - * @author Sebastian Sdorra - */ -public class ScmErrorModule extends ServletModule -{ - - /** - * Method description - * - */ - @Override - protected void configureServlets() - { - SCMContextProvider context = SCMContext.getContext(); - - bind(SCMContextProvider.class).toInstance(context); - - Multibinder engineBinder = - Multibinder.newSetBinder(binder(), TemplateEngine.class); - - engineBinder.addBinding().to(MustacheTemplateEngine.class); - bind(TemplateEngine.class).annotatedWith(Default.class).to( - MustacheTemplateEngine.class); - bind(TemplateEngineFactory.class); - - serve(ScmServletModule.PATTERN_ALL).with(ErrorServlet.class); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextFilter.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextFilter.java index 9c0648e22f..754359ad94 100644 --- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextFilter.java @@ -37,8 +37,6 @@ import com.github.legman.Subscribe; import com.google.inject.servlet.GuiceFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.SCMContext; -import sonia.scm.Stage; import sonia.scm.event.RecreateEventBusEvent; import sonia.scm.event.ScmEventBus; @@ -104,11 +102,8 @@ public class BootstrapContextFilter extends GuiceFilter initGuice(); - if (SCMContext.getContext().getStage() == Stage.DEVELOPMENT) - { - logger.info("register for restart events"); - ScmEventBus.getInstance().register(this); - } + logger.info("register for restart events"); + ScmEventBus.getInstance().register(this); } public void initGuice() throws ServletException { diff --git a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java index 6ca537673a..378016a8fd 100644 --- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java @@ -44,6 +44,7 @@ import sonia.scm.SCMContext; import sonia.scm.ScmContextListener; import sonia.scm.ScmEventBusModule; import sonia.scm.ScmInitializerModule; +import sonia.scm.migration.UpdateException; import sonia.scm.plugin.DefaultPluginLoader; import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginException; @@ -52,6 +53,7 @@ import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginWrapper; import sonia.scm.plugin.PluginsInternal; import sonia.scm.plugin.SmpArchive; +import sonia.scm.update.MigrationWizardContextListener; import sonia.scm.update.UpdateEngine; import sonia.scm.util.ClassLoaders; import sonia.scm.util.IOUtil; @@ -59,13 +61,13 @@ import sonia.scm.util.IOUtil; import javax.servlet.ServletContext; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; +import javax.servlet.http.HttpServletResponse; import javax.xml.bind.DataBindingException; import javax.xml.bind.JAXB; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; -import java.io.Closeable; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; @@ -108,18 +110,6 @@ public class BootstrapContextListener implements ServletContextListener { public void contextDestroyed(ServletContextEvent sce) { contextListener.contextDestroyed(sce); - for (PluginWrapper plugin : contextListener.getPlugins()) { - ClassLoader pcl = plugin.getClassLoader(); - - if (pcl instanceof Closeable) { - try { - ((Closeable) pcl).close(); - } catch (IOException ex) { - logger.warn("could not close plugin classloader", ex); - } - } - } - context = null; contextListener = null; } @@ -134,35 +124,79 @@ public class BootstrapContextListener implements ServletContextListener { public void contextInitialized(ServletContextEvent sce) { context = sce.getServletContext(); - File pluginDirectory = getPluginDirectory(); - - createContextListener(pluginDirectory); + createContextListener(); contextListener.contextInitialized(sce); } - private void createContextListener(File pluginDirectory) { + private void createContextListener() { + Throwable startupError = SCMContext.getContext().getStartupError(); + if (startupError != null) { + contextListener = SingleView.error(startupError); + } else if (Versions.isTooOld()) { + contextListener = SingleView.view("/templates/too-old.mustache", HttpServletResponse.SC_CONFLICT); + } else { + createMigrationOrNormalContextListener(); + Versions.writeNew(); + } + } + + private void createMigrationOrNormalContextListener() { + ClassLoader cl; + Set plugins; + PluginLoader pluginLoader; + try { + File pluginDirectory = getPluginDirectory(); + + renameOldPluginsFolder(pluginDirectory); + if (!isCorePluginExtractionDisabled()) { extractCorePlugins(context, pluginDirectory); } else { logger.info("core plugin extraction is disabled"); } - ClassLoader cl = ClassLoaders.getContextClassLoader(BootstrapContextListener.class); + cl = ClassLoaders.getContextClassLoader(BootstrapContextListener.class); - Set plugins = PluginsInternal.collectPlugins(cl, pluginDirectory.toPath()); + plugins = PluginsInternal.collectPlugins(cl, pluginDirectory.toPath()); - PluginLoader pluginLoader = new DefaultPluginLoader(context, cl, plugins); + pluginLoader = new DefaultPluginLoader(context, cl, plugins); - Injector bootstrapInjector = createBootstrapInjector(pluginLoader); - - processUpdates(pluginLoader, bootstrapInjector); - - contextListener = bootstrapInjector.getInstance(ScmContextListener.Factory.class).create(cl, plugins); } catch (IOException ex) { throw new PluginLoadException("could not load plugins", ex); } + + Injector bootstrapInjector = createBootstrapInjector(pluginLoader); + + startEitherMigrationOrNormalServlet(cl, plugins, pluginLoader, bootstrapInjector); + } + + private void startEitherMigrationOrNormalServlet(ClassLoader cl, Set plugins, PluginLoader pluginLoader, Injector bootstrapInjector) { + MigrationWizardContextListener wizardContextListener = prepareWizardIfNeeded(bootstrapInjector); + + if (wizardContextListener.wizardNecessary()) { + contextListener = wizardContextListener; + } else { + processUpdates(pluginLoader, bootstrapInjector); + contextListener = bootstrapInjector.getInstance(ScmContextListener.Factory.class).create(cl, plugins); + } + } + + private void renameOldPluginsFolder(File pluginDirectory) { + if (new File(pluginDirectory, "classpath.xml").exists()) { + File backupDirectory = new File(pluginDirectory.getParentFile(), "plugins.v1"); + boolean renamed = pluginDirectory.renameTo(backupDirectory); + if (renamed) { + logger.warn("moved old plugins directory to {}", backupDirectory); + } else { + throw new UpdateException("could not rename existing v1 plugin directory"); + } + } + } + + private MigrationWizardContextListener prepareWizardIfNeeded(Injector bootstrapInjector) { + return new MigrationWizardContextListener(bootstrapInjector); } private Injector createBootstrapInjector(PluginLoader pluginLoader) { @@ -393,7 +427,7 @@ public class BootstrapContextListener implements ServletContextListener { private ServletContext context; /** Field description */ - private ScmContextListener contextListener; + private ServletContextListener contextListener; private static class ScmContextListenerModule extends AbstractModule { @Override diff --git a/scm-webapp/src/main/java/sonia/scm/boot/SingleView.java b/scm-webapp/src/main/java/sonia/scm/boot/SingleView.java new file mode 100644 index 0000000000..72e2c3522f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/SingleView.java @@ -0,0 +1,119 @@ +package sonia.scm.boot; + +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableMap; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.servlet.GuiceServletContextListener; +import com.google.inject.servlet.ServletModule; +import sonia.scm.Default; +import sonia.scm.SCMContext; +import sonia.scm.SCMContextProvider; +import sonia.scm.template.MustacheTemplateEngine; +import sonia.scm.template.TemplateEngine; +import sonia.scm.template.TemplateEngineFactory; + +import javax.servlet.ServletContext; +import javax.servlet.ServletContextListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +final class SingleView { + + private SingleView() { + } + + static ServletContextListener error(Throwable throwable) { + String error = Throwables.getStackTraceAsString(throwable); + + ViewController controller = new SimpleViewController("/templates/error.mustache", request -> { + Object model = ImmutableMap.of( + "contextPath", request.getContextPath(), + "error", error + ); + return new View(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, model); + }); + return new SingleViewContextListener(controller); + } + + static ServletContextListener view(String template, int sc) { + ViewController controller = new SimpleViewController(template, request -> { + Object model = ImmutableMap.of( + "contextPath", request.getContextPath() + ); + return new View(sc, model); + }); + return new SingleViewContextListener(controller); + } + + private static class SingleViewContextListener extends GuiceServletContextListener { + + private final ViewController controller; + + private SingleViewContextListener(ViewController controller) { + this.controller = controller; + } + + @Override + protected Injector getInjector() { + return Guice.createInjector(new SingleViewModule(controller)); + } + } + + private static class SingleViewModule extends ServletModule { + + private final ViewController viewController; + + private SingleViewModule(ViewController viewController) { + this.viewController = viewController; + } + + @Override + protected void configureServlets() { + SCMContextProvider context = SCMContext.getContext(); + + bind(SCMContextProvider.class).toInstance(context); + bind(ViewController.class).toInstance(viewController); + + Multibinder engineBinder = + Multibinder.newSetBinder(binder(), TemplateEngine.class); + + engineBinder.addBinding().to(MustacheTemplateEngine.class); + bind(TemplateEngine.class).annotatedWith(Default.class).to( + MustacheTemplateEngine.class); + bind(TemplateEngineFactory.class); + + bind(ServletContext.class).annotatedWith(Default.class).toInstance(getServletContext()); + + serve("/images/*", "/styles/*", "/favicon.ico").with(StaticResourceServlet.class); + serve("/*").with(SingleViewServlet.class); + } + } + + private static class SimpleViewController implements ViewController { + + private final String template; + private final SimpleViewFactory viewFactory; + + private SimpleViewController(String template, SimpleViewFactory viewFactory) { + this.template = template; + this.viewFactory = viewFactory; + } + + @Override + public String getTemplate() { + return template; + } + + @Override + public View createView(HttpServletRequest request) { + return viewFactory.create(request); + } + } + + @FunctionalInterface + interface SimpleViewFactory { + View create(HttpServletRequest request); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java b/scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java new file mode 100644 index 0000000000..3621105976 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/SingleViewServlet.java @@ -0,0 +1,63 @@ +package sonia.scm.boot; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.template.Template; +import sonia.scm.template.TemplateEngine; +import sonia.scm.template.TemplateEngineFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +@Singleton +public class SingleViewServlet extends HttpServlet { + + private static final Logger LOG = LoggerFactory.getLogger(SingleViewServlet.class); + + private final Template template; + private final ViewController controller; + + @Inject + public SingleViewServlet(TemplateEngineFactory templateEngineFactory, ViewController controller) { + template = createTemplate(templateEngineFactory, controller.getTemplate()); + this.controller = controller; + } + + private Template createTemplate(TemplateEngineFactory templateEngineFactory, String template) { + TemplateEngine engine = templateEngineFactory.getEngineByExtension(template); + try { + return engine.getTemplate(template); + } catch (IOException e) { + throw new IllegalStateException("failed to parse template: " + template, e); + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + process(req, resp); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + process(req, resp); + } + + private void process(HttpServletRequest request, HttpServletResponse response) { + View view = controller.createView(request); + + response.setStatus(view.getStatusCode()); + response.setContentType("text/html"); + response.setCharacterEncoding("UTF-8"); + + try (PrintWriter writer = response.getWriter()) { + template.execute(writer, view.getModel()); + } catch (IOException ex) { + LOG.error("failed to write view", ex); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java b/scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java new file mode 100644 index 0000000000..b44fbb64bc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/StaticResourceServlet.java @@ -0,0 +1,39 @@ +package sonia.scm.boot; + +import com.github.sdorra.webresources.CacheControl; +import com.github.sdorra.webresources.WebResourceSender; +import com.google.common.annotations.VisibleForTesting; +import sonia.scm.util.HttpUtil; + +import javax.inject.Singleton; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +@Singleton +public class StaticResourceServlet extends HttpServlet { + + private final WebResourceSender sender = WebResourceSender.create() + .withGZIP() + .withGZIPMinLength(512) + .withBufferSize(16384) + .withCacheControl(CacheControl.create().noCache()); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + URL resource = createResourceUrlFromRequest(request); + if (resource != null) { + sender.resource(resource).get(request, response); + } else { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + } + + private URL createResourceUrlFromRequest(HttpServletRequest request) throws MalformedURLException { + String uri = HttpUtil.getStrippedURI(request); + return request.getServletContext().getResource(uri); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/Versions.java b/scm-webapp/src/main/java/sonia/scm/boot/Versions.java new file mode 100644 index 0000000000..6da19fedca --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/Versions.java @@ -0,0 +1,77 @@ +package sonia.scm.boot; + +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContext; +import sonia.scm.SCMContextProvider; +import sonia.scm.util.IOUtil; +import sonia.scm.version.Version; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +class Versions { + + private static final Logger LOG = LoggerFactory.getLogger(Versions.class); + + private static final Version MIN_VERSION = Version.parse("1.60"); + + private final SCMContextProvider contextProvider; + + @VisibleForTesting + Versions(SCMContextProvider contextProvider) { + this.contextProvider = contextProvider; + } + + @VisibleForTesting + boolean isPreviousVersionTooOld() { + return readVersion().map(v -> v.isOlder(MIN_VERSION)).orElse(false); + } + + @VisibleForTesting + void writeNewVersion() { + Path config = contextProvider.resolve(Paths.get("config")); + IOUtil.mkdirs(config.toFile()); + + String version = contextProvider.getVersion(); + LOG.debug("write new version {} to file", version); + Path versionFile = config.resolve("version.txt"); + try { + Files.write(versionFile, version.getBytes()); + } catch (IOException e) { + throw new IllegalStateException("failed to write version file", e); + } + } + + private Optional readVersion() { + Path versionFile = contextProvider.resolve(Paths.get("config", "version.txt")); + if (versionFile.toFile().exists()) { + return Optional.of(readVersionFromFile(versionFile)); + } + return Optional.empty(); + } + + private Version readVersionFromFile(Path versionFile) { + try { + String versionString = new String(Files.readAllBytes(versionFile), StandardCharsets.UTF_8).trim(); + LOG.debug("read previous version {} from file", versionString); + return Version.parse(versionString); + } catch (IOException e) { + throw new IllegalStateException("failed to read version file", e); + } + } + + static boolean isTooOld() { + return new Versions(SCMContext.getContext()).isPreviousVersionTooOld(); + } + + static void writeNew() { + new Versions(SCMContext.getContext()).writeNewVersion(); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/View.java b/scm-webapp/src/main/java/sonia/scm/boot/View.java new file mode 100644 index 0000000000..6e1f93bd3f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/View.java @@ -0,0 +1,20 @@ +package sonia.scm.boot; + +class View { + + private final int statusCode; + private final Object model; + + View(int statusCode, Object model) { + this.statusCode = statusCode; + this.model = model; + } + + int getStatusCode() { + return statusCode; + } + + Object getModel() { + return model; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/boot/ViewController.java b/scm-webapp/src/main/java/sonia/scm/boot/ViewController.java new file mode 100644 index 0000000000..26f463f9c2 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/boot/ViewController.java @@ -0,0 +1,11 @@ +package sonia.scm.boot; + +import javax.servlet.http.HttpServletRequest; + +public interface ViewController { + + String getTemplate(); + + View createView(HttpServletRequest request); + +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java b/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java index 0350698352..444ee3e941 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java +++ b/scm-webapp/src/main/java/sonia/scm/security/SystemRepositoryPermissionProvider.java @@ -25,7 +25,7 @@ import java.util.Set; import static java.util.Collections.unmodifiableCollection; import static java.util.stream.Collectors.toList; -class SystemRepositoryPermissionProvider { +public class SystemRepositoryPermissionProvider { private static final Logger logger = LoggerFactory.getLogger(SystemRepositoryPermissionProvider.class); private static final String REPOSITORY_PERMISSION_DESCRIPTOR = "META-INF/scm/repository-permissions.xml"; diff --git a/scm-webapp/src/main/java/sonia/scm/template/ErrorServlet.java b/scm-webapp/src/main/java/sonia/scm/template/ErrorServlet.java deleted file mode 100644 index 4d62b823a1..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/template/ErrorServlet.java +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Copyright (c) 2010, Sebastian Sdorra - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * http://bitbucket.org/sdorra/scm-manager - * - */ - - - -package sonia.scm.template; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.base.Throwables; -import com.google.inject.Inject; -import com.google.inject.Singleton; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import sonia.scm.SCMContextProvider; -import sonia.scm.util.IOUtil; -import sonia.scm.util.Util; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.IOException; -import java.io.PrintWriter; - -import java.util.HashMap; -import java.util.Map; - -import javax.servlet.ServletException; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -/** - * - * @author Sebastian Sdorra - */ -@Singleton -public class ErrorServlet extends HttpServlet -{ - - /** Field description */ - private static final String TEMPALTE = "/error.mustache"; - - /** Field description */ - private static final long serialVersionUID = -3289076078469757874L; - - /** - * the logger for ErrorServlet - */ - private static final Logger logger = - LoggerFactory.getLogger(ErrorServlet.class); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param context - * @param templateEngineFactory - */ - @Inject - public ErrorServlet(SCMContextProvider context, - TemplateEngineFactory templateEngineFactory) - { - this.context = context; - this.templateEngineFactory = templateEngineFactory; - } - - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - * - * @param request - * @param response - * - * @throws IOException - * @throws ServletException - */ - @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException - { - processRequest(request, response); - } - - /** - * Method description - * - * - * @param request - * @param response - * - * @throws IOException - * @throws ServletException - */ - @Override - protected void doPost(HttpServletRequest request, - HttpServletResponse response) - throws ServletException, IOException - { - processRequest(request, response); - } - - /** - * Method description - * - * - * @param request - * @param response - * - * @throws IOException - * @throws ServletException - */ - private void processRequest(HttpServletRequest request, - HttpServletResponse response) - throws ServletException, IOException - { - PrintWriter writer = null; - - try - { - writer = response.getWriter(); - - Map env = new HashMap(); - String error = Util.EMPTY_STRING; - - if (context.getStartupError() != null) - { - error = Throwables.getStackTraceAsString(context.getStartupError()); - } - - env.put("error", error); - - TemplateEngine engine = templateEngineFactory.getDefaultEngine(); - Template template = engine.getTemplate(TEMPALTE); - - if (template != null) - { - template.execute(writer, env); - } - else if (logger.isWarnEnabled()) - { - logger.warn("could not find template {}", TEMPALTE); - } - } - finally - { - IOUtil.close(writer); - } - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private final SCMContextProvider context; - - /** Field description */ - private final TemplateEngineFactory templateEngineFactory; -} diff --git a/scm-webapp/src/main/java/sonia/scm/template/MustacheTemplateEngine.java b/scm-webapp/src/main/java/sonia/scm/template/MustacheTemplateEngine.java index 06bd8b0f62..170c0cd641 100644 --- a/scm-webapp/src/main/java/sonia/scm/template/MustacheTemplateEngine.java +++ b/scm-webapp/src/main/java/sonia/scm/template/MustacheTemplateEngine.java @@ -37,27 +37,22 @@ package sonia.scm.template; import com.github.mustachejava.Mustache; import com.github.mustachejava.MustacheException; - import com.google.common.base.Throwables; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.common.util.concurrent.UncheckedExecutionException; import com.google.inject.Inject; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.Default; import sonia.scm.plugin.PluginLoader; -//~--- JDK imports ------------------------------------------------------------ - +import javax.servlet.ServletContext; import java.io.IOException; import java.io.Reader; - import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; -import javax.servlet.ServletContext; +//~--- JDK imports ------------------------------------------------------------ /** * @@ -67,6 +62,14 @@ import javax.servlet.ServletContext; public class MustacheTemplateEngine implements TemplateEngine { + /** + * Used to implement optional injection for the PluginLoader. + * @see Optional Injection + */ + static class PluginLoaderHolder { + @Inject(optional = true) PluginLoader pluginLoader; + } + /** Field description */ public static final TemplateType TYPE = new TemplateType("mustache", "Mustache", "mustache"); @@ -87,13 +90,12 @@ public class MustacheTemplateEngine implements TemplateEngine * * * @param context - * @param pluginLoader + * @param pluginLoaderHolder */ @Inject - public MustacheTemplateEngine(@Default ServletContext context, - PluginLoader pluginLoader) + public MustacheTemplateEngine(@Default ServletContext context, PluginLoaderHolder pluginLoaderHolder) { - factory = new ServletMustacheFactory(context, pluginLoader); + factory = new ServletMustacheFactory(context, createClassLoader(pluginLoaderHolder.pluginLoader)); ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(THREAD_NAME).build(); @@ -101,6 +103,13 @@ public class MustacheTemplateEngine implements TemplateEngine factory.setExecutorService(Executors.newCachedThreadPool(threadFactory)); } + private ClassLoader createClassLoader(PluginLoader pluginLoader) { + if (pluginLoader == null) { + return Thread.currentThread().getContextClassLoader(); + } + return pluginLoader.getUberClassLoader(); + } + //~--- get methods ---------------------------------------------------------- /** @@ -112,12 +121,9 @@ public class MustacheTemplateEngine implements TemplateEngine * * @return * - * @throws IOException */ @Override - public Template getTemplate(String templateIdentifier, Reader reader) - throws IOException - { + public Template getTemplate(String templateIdentifier, Reader reader) { if (logger.isTraceEnabled()) { logger.trace("try to create mustache template from reader with id {}", diff --git a/scm-webapp/src/main/java/sonia/scm/template/ServletMustacheFactory.java b/scm-webapp/src/main/java/sonia/scm/template/ServletMustacheFactory.java index c6e5b82441..24c8ff21c9 100644 --- a/scm-webapp/src/main/java/sonia/scm/template/ServletMustacheFactory.java +++ b/scm-webapp/src/main/java/sonia/scm/template/ServletMustacheFactory.java @@ -36,22 +36,17 @@ package sonia.scm.template; //~--- non-JDK imports -------------------------------------------------------- import com.github.mustachejava.DefaultMustacheFactory; - import com.google.common.base.Charsets; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.plugin.PluginLoader; - -//~--- JDK imports ------------------------------------------------------------ - +import javax.servlet.ServletContext; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; -import javax.servlet.ServletContext; +//~--- JDK imports ------------------------------------------------------------ /** * @@ -73,13 +68,12 @@ public class ServletMustacheFactory extends DefaultMustacheFactory * * * @param servletContext - * @param pluginLoader + * @param classLoader */ - public ServletMustacheFactory(ServletContext servletContext, - PluginLoader pluginLoader) + public ServletMustacheFactory(ServletContext servletContext, ClassLoader classLoader) { this.servletContext = servletContext; - this.pluginLoader = pluginLoader; + this.classLoader = classLoader; } //~--- get methods ---------------------------------------------------------- @@ -116,7 +110,7 @@ public class ServletMustacheFactory extends DefaultMustacheFactory resourceName = resourceName.substring(1); } - is = pluginLoader.getUberClassLoader().getResourceAsStream(resourceName); + is = classLoader.getResourceAsStream(resourceName); } if (is != null) @@ -144,9 +138,8 @@ public class ServletMustacheFactory extends DefaultMustacheFactory //~--- fields --------------------------------------------------------------- - /** Field description */ - private final PluginLoader pluginLoader; - /** Field description */ private ServletContext servletContext; + + private ClassLoader classLoader; } diff --git a/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardContextListener.java b/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardContextListener.java new file mode 100644 index 0000000000..6929c8b10a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardContextListener.java @@ -0,0 +1,23 @@ +package sonia.scm.update; + +import com.google.inject.Injector; +import com.google.inject.servlet.GuiceServletContextListener; +import sonia.scm.update.repository.XmlRepositoryV1UpdateStep; + +public class MigrationWizardContextListener extends GuiceServletContextListener { + + private final Injector bootstrapInjector; + + public MigrationWizardContextListener(Injector bootstrapInjector) { + this.bootstrapInjector = bootstrapInjector; + } + + public boolean wizardNecessary() { + return !bootstrapInjector.getInstance(XmlRepositoryV1UpdateStep.class).getRepositoriesWithoutMigrationStrategies().isEmpty(); + } + + @Override + protected Injector getInjector() { + return bootstrapInjector.createChildInjector(new MigrationWizardModule()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardModule.java b/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardModule.java new file mode 100644 index 0000000000..4b357b6d96 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardModule.java @@ -0,0 +1,26 @@ +package sonia.scm.update; + +import com.google.inject.servlet.ServletModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.PushStateDispatcher; +import sonia.scm.WebResourceServlet; + +class MigrationWizardModule extends ServletModule { + + private static final Logger LOG = LoggerFactory.getLogger(MigrationWizardModule.class); + + @Override + protected void configureServlets() { + LOG.info("=========================================================="); + LOG.info("= ="); + LOG.info("= STARTING MIGRATION SERVLET ="); + LOG.info("= ="); + LOG.info("= Open SCM-Manager in a browser to start the wizard. ="); + LOG.info("= ="); + LOG.info("=========================================================="); + bind(PushStateDispatcher.class).toInstance((request, response, uri) -> {}); + serve("/images/*", "/styles/*", "/favicon.ico").with(WebResourceServlet.class); + serve("/*").with(MigrationWizardServlet.class); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardServlet.java b/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardServlet.java new file mode 100644 index 0000000000..479778852b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/MigrationWizardServlet.java @@ -0,0 +1,265 @@ +package sonia.scm.update; + +import com.github.mustachejava.DefaultMustacheFactory; +import com.github.mustachejava.Mustache; +import com.github.mustachejava.MustacheFactory; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.boot.RestartEvent; +import sonia.scm.event.ScmEventBus; +import sonia.scm.update.repository.MigrationStrategy; +import sonia.scm.update.repository.MigrationStrategyDao; +import sonia.scm.update.repository.V1Repository; +import sonia.scm.update.repository.XmlRepositoryV1UpdateStep; +import sonia.scm.util.ValidationUtil; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.Comparator.comparing; + +@Singleton +class MigrationWizardServlet extends HttpServlet { + + private static final Logger LOG = LoggerFactory.getLogger(MigrationWizardServlet.class); + + private final XmlRepositoryV1UpdateStep repositoryV1UpdateStep; + private final MigrationStrategyDao migrationStrategyDao; + + @Inject + MigrationWizardServlet(XmlRepositoryV1UpdateStep repositoryV1UpdateStep, MigrationStrategyDao migrationStrategyDao) { + this.repositoryV1UpdateStep = repositoryV1UpdateStep; + this.migrationStrategyDao = migrationStrategyDao; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + List repositoryLineEntries = getRepositoryLineEntries(); + doGet(req, resp, repositoryLineEntries); + } + + private void doGet(HttpServletRequest req, HttpServletResponse resp, List repositoryLineEntries) { + HashMap model = new HashMap<>(); + + model.put("contextPath", req.getContextPath()); + model.put("submitUrl", req.getRequestURI()); + model.put("repositories", repositoryLineEntries); + model.put("strategies", getMigrationStrategies()); + model.put("validationErrorsFound", repositoryLineEntries + .stream() + .anyMatch(entry -> entry.isNamespaceInvalid() || entry.isNameInvalid())); + + respondWithTemplate(resp, model, "templates/repository-migration.mustache"); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + List repositoryLineEntries = getRepositoryLineEntries(); + + boolean validationErrorFound = false; + for (RepositoryLineEntry repositoryLineEntry : repositoryLineEntries) { + String id = repositoryLineEntry.getId(); + + String strategy = req.getParameter("strategy-" + id); + if (!Strings.isNullOrEmpty(strategy)) { + repositoryLineEntry.setSelectedStrategy(MigrationStrategy.valueOf(strategy)); + } + + String namespace = req.getParameter("namespace-" + id); + repositoryLineEntry.setNamespace(namespace); + + String name = req.getParameter("name-" + id); + repositoryLineEntry.setName(name); + + if (!ValidationUtil.isRepositoryNameValid(namespace)) { + repositoryLineEntry.setNamespaceValid(false); + validationErrorFound = true; + } + if (!ValidationUtil.isRepositoryNameValid(name)) { + repositoryLineEntry.setNameValid(false); + validationErrorFound = true; + } + } + + if (validationErrorFound) { + doGet(req, resp, repositoryLineEntries); + return; + } + + repositoryLineEntries.stream() + .map(RepositoryLineEntry::getId) + .forEach( + id -> { + String strategy = req.getParameter("strategy-" + id); + String namespace = req.getParameter("namespace-" + id); + String name = req.getParameter("name-" + id); + migrationStrategyDao.set(id, MigrationStrategy.valueOf(strategy), namespace, name); + } + ); + + Map model = Collections.singletonMap("contextPath", req.getContextPath()); + + respondWithTemplate(resp, model, "templates/repository-migration-restart.mustache"); + + ScmEventBus.getInstance().post(new RestartEvent(MigrationWizardServlet.class, "wrote migration data")); + } + + private List getRepositoryLineEntries() { + List repositoriesWithoutMigrationStrategies = + repositoryV1UpdateStep.getRepositoriesWithoutMigrationStrategies(); + return repositoriesWithoutMigrationStrategies.stream() + .map(RepositoryLineEntry::new) + .sorted(comparing(RepositoryLineEntry::getPath)) + .collect(Collectors.toList()); + } + + private MigrationStrategy[] getMigrationStrategies() { + return MigrationStrategy.values(); + } + + @VisibleForTesting + void respondWithTemplate(HttpServletResponse resp, Map model, String templateName) { + MustacheFactory mf = new DefaultMustacheFactory(); + Mustache template = mf.compile(templateName); + + PrintWriter writer; + try { + writer = resp.getWriter(); + } catch (IOException e) { + LOG.error("could not create writer for response", e); + resp.setStatus(500); + return; + } + template.execute(writer, model); + writer.flush(); + resp.setStatus(200); + } + + private static class RepositoryLineEntry { + private final String id; + private final String type; + private final String path; + private MigrationStrategy selectedStrategy; + private String namespace; + private String name; + private boolean namespaceValid = true; + private boolean nameValid = true; + + public RepositoryLineEntry(V1Repository repository) { + this.id = repository.getId(); + this.type = repository.getType(); + this.path = repository.getType() + "/" + repository.getName(); + this.selectedStrategy = MigrationStrategy.COPY; + this.namespace = computeNewNamespace(repository); + this.name = computeNewName(repository); + } + + private static String computeNewNamespace(V1Repository v1Repository) { + String[] nameParts = getNameParts(v1Repository.getName()); + return nameParts.length > 1 ? nameParts[0] : v1Repository.getType(); + } + + private static String computeNewName(V1Repository v1Repository) { + String[] nameParts = getNameParts(v1Repository.getName()); + return nameParts.length == 1 ? nameParts[0] : concatPathElements(nameParts); + } + + private static String[] getNameParts(String v1Name) { + return v1Name.split("/"); + } + + private static String concatPathElements(String[] nameParts) { + return Arrays.stream(nameParts).skip(1).collect(Collectors.joining("_")); + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getPath() { + return path; + } + + public String getNamespace() { + return namespace; + } + + public String getName() { + return name; + } + + public MigrationStrategy getSelectedStrategy() { + return selectedStrategy; + } + + public List getStrategies() { + return Arrays.stream(MigrationStrategy.values()) + .map(s -> new RepositoryLineMigrationStrategy(s.name(), selectedStrategy == s)) + .collect(Collectors.toList()); + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public void setName(String name) { + this.name = name; + } + + public void setNamespaceValid(boolean namespaceValid) { + this.namespaceValid = namespaceValid; + } + + public void setNameValid(boolean nameValid) { + this.nameValid = nameValid; + } + + public void setSelectedStrategy(MigrationStrategy selectedStrategy) { + this.selectedStrategy = selectedStrategy; + } + + public boolean isNamespaceInvalid() { + return !namespaceValid; + } + + public boolean isNameInvalid() { + return !nameValid; + } + } + + private static class RepositoryLineMigrationStrategy { + + private final String name; + private final boolean selected; + + private RepositoryLineMigrationStrategy(String name, boolean selected) { + this.name = name; + this.selected = selected; + } + + public String getName() { + return name; + } + + public boolean isSelected() { + return selected; + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/CopyMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/update/repository/CopyMigrationStrategy.java index 060ed6704e..b66b2ea9c9 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/CopyMigrationStrategy.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/CopyMigrationStrategy.java @@ -1,5 +1,7 @@ package sonia.scm.update.repository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import sonia.scm.SCMContextProvider; import sonia.scm.repository.RepositoryDirectoryHandler; import sonia.scm.repository.RepositoryLocationResolver; @@ -7,9 +9,14 @@ import sonia.scm.repository.RepositoryLocationResolver; import javax.inject.Inject; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; + +import static java.util.Optional.of; class CopyMigrationStrategy extends BaseMigrationStrategy { + private static final Logger LOG = LoggerFactory.getLogger(CopyMigrationStrategy.class); + private final RepositoryLocationResolver locationResolver; @Inject @@ -19,13 +26,14 @@ class CopyMigrationStrategy extends BaseMigrationStrategy { } @Override - public Path migrate(String id, String name, String type) { - Path repositoryBasePath = locationResolver.forClass(Path.class).getLocation(id); + public Optional migrate(String id, String name, String type) { + Path repositoryBasePath = locationResolver.forClass(Path.class).createLocation(id); Path targetDataPath = repositoryBasePath .resolve(RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); Path sourceDataPath = getSourceDataPath(name, type); + LOG.info("copying repository data from {} to {}", sourceDataPath, targetDataPath); copyData(sourceDataPath, targetDataPath); - return repositoryBasePath; + return of(repositoryBasePath); } private void copyData(Path sourceDirectory, Path targetDirectory) { diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/DeleteMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/update/repository/DeleteMigrationStrategy.java new file mode 100644 index 0000000000..cf61152613 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/DeleteMigrationStrategy.java @@ -0,0 +1,32 @@ +package sonia.scm.update.repository; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.util.IOUtil; + +import javax.inject.Inject; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +public class DeleteMigrationStrategy extends BaseMigrationStrategy { + + private static final Logger LOG = LoggerFactory.getLogger(DeleteMigrationStrategy.class); + + @Inject + DeleteMigrationStrategy(SCMContextProvider contextProvider) { + super(contextProvider); + } + + @Override + public Optional migrate(String id, String name, String type) { + Path sourceDataPath = getSourceDataPath(name, type); + try { + IOUtil.delete(sourceDataPath.toFile(), true); + } catch (IOException e) { + LOG.warn("could not delete old repository path for repository {} with type {} and id {}", name, type, id); + } + return Optional.empty(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/IgnoreMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/update/repository/IgnoreMigrationStrategy.java new file mode 100644 index 0000000000..945538009a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/IgnoreMigrationStrategy.java @@ -0,0 +1,13 @@ +package sonia.scm.update.repository; + +import java.nio.file.Path; +import java.util.Optional; + +import static java.util.Optional.empty; + +public class IgnoreMigrationStrategy implements MigrationStrategy.Instance { + @Override + public Optional migrate(String id, String name, String type) { + return empty(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/InlineMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/update/repository/InlineMigrationStrategy.java index 62dd67d86a..a75ff5e571 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/InlineMigrationStrategy.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/InlineMigrationStrategy.java @@ -1,29 +1,47 @@ package sonia.scm.update.repository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import sonia.scm.SCMContextProvider; import sonia.scm.repository.RepositoryDirectoryHandler; +import sonia.scm.repository.RepositoryLocationResolver; import javax.inject.Inject; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; + +import static java.util.Optional.of; class InlineMigrationStrategy extends BaseMigrationStrategy { + private static final Logger LOG = LoggerFactory.getLogger(InlineMigrationStrategy.class); + + private final RepositoryLocationResolver locationResolver; + @Inject - public InlineMigrationStrategy(SCMContextProvider contextProvider) { + public InlineMigrationStrategy(SCMContextProvider contextProvider, RepositoryLocationResolver locationResolver) { super(contextProvider); + this.locationResolver = locationResolver; } @Override - public Path migrate(String id, String name, String type) { + public Optional migrate(String id, String name, String type) { Path repositoryBasePath = getSourceDataPath(name, type); + locationResolver.forClass(Path.class).setLocation(id, repositoryBasePath); Path targetDataPath = repositoryBasePath .resolve(RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); + LOG.info("moving repository data from {} to {}", repositoryBasePath, targetDataPath); moveData(repositoryBasePath, targetDataPath); - return repositoryBasePath; + return of(repositoryBasePath); } private void moveData(Path sourceDirectory, Path targetDirectory) { + moveData(sourceDirectory, targetDirectory, false); + } + + private void moveData(Path sourceDirectory, Path targetDirectory, boolean deleteDirectory) { createDataDirectory(targetDirectory); listSourceDirectory(sourceDirectory) .filter(sourceFile -> !targetDirectory.equals(sourceFile)) @@ -31,11 +49,18 @@ class InlineMigrationStrategy extends BaseMigrationStrategy { sourceFile -> { Path targetFile = targetDirectory.resolve(sourceFile.getFileName()); if (Files.isDirectory(sourceFile)) { - moveData(sourceFile, targetFile); + moveData(sourceFile, targetFile, true); } else { moveFile(sourceFile, targetFile); } } ); + if (deleteDirectory) { + try { + Files.delete(sourceDirectory); + } catch (IOException e) { + LOG.warn("could not delete source repository directory {}", sourceDirectory); + } + } } } diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java new file mode 100644 index 0000000000..0b96a58385 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrateVerbsToPermissionRoles.java @@ -0,0 +1,158 @@ +package sonia.scm.update.repository; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.migration.UpdateException; +import sonia.scm.migration.UpdateStep; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryRole; +import sonia.scm.repository.xml.SingleRepositoryUpdateProcessor; +import sonia.scm.security.SystemRepositoryPermissionProvider; +import sonia.scm.version.Version; + +import javax.inject.Inject; +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlElementWrapper; +import javax.xml.bind.annotation.XmlRootElement; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Extension +public class MigrateVerbsToPermissionRoles implements UpdateStep { + + public static final Logger LOG = LoggerFactory.getLogger(MigrateVerbsToPermissionRoles.class); + + private final SingleRepositoryUpdateProcessor updateProcessor; + private final SystemRepositoryPermissionProvider systemRepositoryPermissionProvider; + private final JAXBContext jaxbContextNewRepository; + private final JAXBContext jaxbContextOldRepository; + + @Inject + public MigrateVerbsToPermissionRoles(SingleRepositoryUpdateProcessor updateProcessor, SystemRepositoryPermissionProvider systemRepositoryPermissionProvider) { + this.updateProcessor = updateProcessor; + this.systemRepositoryPermissionProvider = systemRepositoryPermissionProvider; + jaxbContextNewRepository = createJAXBContext(Repository.class); + jaxbContextOldRepository = createJAXBContext(OldRepository.class); + } + + @Override + public void doUpdate() { + updateProcessor.doUpdate(this::update); + } + + void update(String repositoryId, Path path) { + LOG.info("updating repository {}", repositoryId); + OldRepository oldRepository = readOldRepository(path); + Repository newRepository = createNewRepository(oldRepository); + writeNewRepository(path, newRepository); + } + + private void writeNewRepository(Path path, Repository newRepository) { + try { + Marshaller marshaller = jaxbContextNewRepository.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + marshaller.marshal(newRepository, path.resolve("metadata.xml").toFile()); + } catch (JAXBException e) { + throw new UpdateException("could not read old repository structure", e); + } + } + + private OldRepository readOldRepository(Path path) { + try { + return (OldRepository) jaxbContextOldRepository.createUnmarshaller().unmarshal(path.resolve("metadata.xml").toFile()); + } catch (JAXBException e) { + throw new UpdateException("could not read old repository structure", e); + } + } + + private Repository createNewRepository(OldRepository oldRepository) { + Repository repository = new Repository( + oldRepository.id, + oldRepository.type, + oldRepository.namespace, + oldRepository.name, + oldRepository.contact, + oldRepository.description, + oldRepository.permissions.stream().map(this::updatePermission).toArray(RepositoryPermission[]::new) + ); + repository.setCreationDate(oldRepository.creationDate); + repository.setHealthCheckFailures(oldRepository.healthCheckFailures); + repository.setLastModified(oldRepository.lastModified); + repository.setPublicReadable(oldRepository.publicReadable); + repository.setArchived(oldRepository.archived); + return repository; + } + + private RepositoryPermission updatePermission(RepositoryPermission repositoryPermission) { + return findMatchingRole(repositoryPermission.getVerbs()) + .map(roleName -> copyRepositoryPermissionWithRole(repositoryPermission, roleName)) + .orElse(repositoryPermission); + } + + private RepositoryPermission copyRepositoryPermissionWithRole(RepositoryPermission repositoryPermission, String roleName) { + return new RepositoryPermission(repositoryPermission.getName(), roleName, repositoryPermission.isGroupPermission()); + } + + private Optional findMatchingRole(Collection verbs) { + return systemRepositoryPermissionProvider.availableRoles() + .stream() + .filter(r -> roleMatchesVerbs(verbs, r)) + .map(RepositoryRole::getName) + .findFirst(); + } + + private boolean roleMatchesVerbs(Collection verbs, RepositoryRole r) { + return verbs.size() == r.getVerbs().size() && r.getVerbs().containsAll(verbs); + } + + private JAXBContext createJAXBContext(Class clazz) { + try { + return JAXBContext.newInstance(clazz); + } catch (JAXBException e) { + throw new UpdateException("could not create XML marshaller", e); + } + } + + @Override + public Version getTargetVersion() { + return Version.parse("2.0.2"); + } + + @Override + public String getAffectedDataType() { + return "sonia.scm.repository.xml"; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlRootElement(name = "repositories") + private static class OldRepository { + private String contact; + private Long creationDate; + private String description; + @XmlElement(name = "healthCheckFailure") + @XmlElementWrapper(name = "healthCheckFailures") + private List healthCheckFailures; + private String id; + private Long lastModified; + private String namespace; + private String name; + @XmlElement(name = "permission") + private final Set permissions = new HashSet<>(); + @XmlElement(name = "public") + private boolean publicReadable = false; + private boolean archived = false; + private String type; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategy.java index c7bb2cba86..46604d327e 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategy.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategy.java @@ -3,17 +3,41 @@ package sonia.scm.update.repository; import com.google.inject.Injector; import java.nio.file.Path; +import java.util.Optional; -enum MigrationStrategy { +public enum MigrationStrategy { - COPY(CopyMigrationStrategy.class), - MOVE(MoveMigrationStrategy.class), - INLINE(InlineMigrationStrategy.class); + COPY(CopyMigrationStrategy.class, + "Copy the repository data files to the new native location inside SCM-Manager home directory. " + + "This will keep the original directory."), + MOVE(MoveMigrationStrategy.class, + "Move the repository data files to the new native location inside SCM-Manager home directory. " + + "The original directory will be deleted."), + INLINE(InlineMigrationStrategy.class, + "Use the current directory where the repository data files are stored, but modify the directory " + + "structure so that it can be used for SCM-Manager v2. The repository data files will be moved to a new " + + "subdirectory 'data' inside the current directory."), + IGNORE(IgnoreMigrationStrategy.class, + "The repository will not be migrated and will not be visible inside SCM-Manager. " + + "The data files will be kept at the current location."), + DELETE(DeleteMigrationStrategy.class, + "The repository will not be migrated and will not be visible inside SCM-Manager. " + + "The data files will be deleted!"); - private Class implementationClass; + private final Class implementationClass; + private final String description; - MigrationStrategy(Class implementationClass) { + MigrationStrategy(Class implementationClass, String description) { this.implementationClass = implementationClass; + this.description = description; + } + + public Class getImplementationClass() { + return implementationClass; + } + + public String getDescription() { + return description; } Instance from(Injector injector) { @@ -21,6 +45,6 @@ enum MigrationStrategy { } interface Instance { - Path migrate(String id, String name, String type); + Optional migrate(String id, String name, String type); } } diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategyDao.java b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategyDao.java index 15c931bf31..5670ce458a 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategyDao.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/MigrationStrategyDao.java @@ -4,8 +4,10 @@ import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStoreFactory; import javax.inject.Inject; +import javax.inject.Singleton; import java.util.Optional; +@Singleton public class MigrationStrategyDao { private final RepositoryMigrationPlan plan; @@ -17,12 +19,12 @@ public class MigrationStrategyDao { this.plan = store.getOptional().orElse(new RepositoryMigrationPlan()); } - public Optional get(String id) { + public Optional get(String id) { return plan.get(id); } - public void set(String repositoryId, MigrationStrategy strategy) { - plan.set(repositoryId, strategy); + public void set(String repositoryId, MigrationStrategy strategy, String newNamespace, String newName) { + plan.set(repositoryId, strategy, newNamespace, newName); store.set(plan); } } diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/MoveMigrationStrategy.java b/scm-webapp/src/main/java/sonia/scm/update/repository/MoveMigrationStrategy.java index 08d71dd376..5b364d8bc3 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/MoveMigrationStrategy.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/MoveMigrationStrategy.java @@ -11,8 +11,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import static java.util.Arrays.asList; +import static java.util.Optional.of; class MoveMigrationStrategy extends BaseMigrationStrategy { @@ -27,14 +29,15 @@ class MoveMigrationStrategy extends BaseMigrationStrategy { } @Override - public Path migrate(String id, String name, String type) { - Path repositoryBasePath = locationResolver.forClass(Path.class).getLocation(id); + public Optional migrate(String id, String name, String type) { + Path repositoryBasePath = locationResolver.forClass(Path.class).createLocation(id); Path targetDataPath = repositoryBasePath .resolve(RepositoryDirectoryHandler.REPOSITORIES_NATIVE_DIRECTORY); Path sourceDataPath = getSourceDataPath(name, type); + LOG.info("moving repository data from {} to {}", sourceDataPath, targetDataPath); moveData(sourceDataPath, targetDataPath); deleteOldDataDir(getTypeDependentPath(type), name); - return repositoryBasePath; + return of(repositoryBasePath); } private void deleteOldDataDir(Path rootPath, String name) { diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/RepositoryMigrationPlan.java b/scm-webapp/src/main/java/sonia/scm/update/repository/RepositoryMigrationPlan.java index f2b2dc9788..3c4bf36342 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/RepositoryMigrationPlan.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/RepositoryMigrationPlan.java @@ -13,57 +13,74 @@ import static java.util.Arrays.asList; @XmlRootElement(name = "repository-migration") class RepositoryMigrationPlan { - private List entries; + private List entries; RepositoryMigrationPlan() { - this(new RepositoryEntry[0]); + this(new RepositoryMigrationEntry[0]); } - RepositoryMigrationPlan(RepositoryEntry... entries) { + RepositoryMigrationPlan(RepositoryMigrationEntry... entries) { this.entries = new ArrayList<>(asList(entries)); } - Optional get(String repositoryId) { - return findEntry(repositoryId) - .map(RepositoryEntry::getDataMigrationStrategy); - } - - public void set(String repositoryId, MigrationStrategy strategy) { - Optional entry = findEntry(repositoryId); - if (entry.isPresent()) { - entry.get().setStrategy(strategy); - } else { - entries.add(new RepositoryEntry(repositoryId, strategy)); - } - } - - private Optional findEntry(String repositoryId) { + Optional get(String repositoryId) { return entries.stream() .filter(repositoryEntry -> repositoryId.equals(repositoryEntry.repositoryId)) .findFirst(); } + public void set(String repositoryId, MigrationStrategy strategy, String newNamespace, String newName) { + Optional entry = get(repositoryId); + if (entry.isPresent()) { + entry.get().setStrategy(strategy); + entry.get().setNewNamespace(newNamespace); + entry.get().setNewName(newName); + } else { + entries.add(new RepositoryMigrationEntry(repositoryId, strategy, newNamespace, newName)); + } + } + @XmlRootElement(name = "entries") @XmlAccessorType(XmlAccessType.FIELD) - static class RepositoryEntry { + static class RepositoryMigrationEntry { private String repositoryId; private MigrationStrategy dataMigrationStrategy; + private String newNamespace; + private String newName; - RepositoryEntry() { + RepositoryMigrationEntry() { } - RepositoryEntry(String repositoryId, MigrationStrategy dataMigrationStrategy) { + RepositoryMigrationEntry(String repositoryId, MigrationStrategy dataMigrationStrategy, String newNamespace, String newName) { this.repositoryId = repositoryId; this.dataMigrationStrategy = dataMigrationStrategy; + this.newNamespace = newNamespace; + this.newName = newName; } public MigrationStrategy getDataMigrationStrategy() { return dataMigrationStrategy; } + public String getNewNamespace() { + return newNamespace; + } + + public String getNewName() { + return newName; + } + private void setStrategy(MigrationStrategy strategy) { this.dataMigrationStrategy = strategy; } + + private void setNewNamespace(String newNamespace) { + this.newNamespace = newNamespace; + } + + private void setNewName(String newName) { + this.newName = newName; + } } } diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/V1Permission.java b/scm-webapp/src/main/java/sonia/scm/update/repository/V1Permission.java new file mode 100644 index 0000000000..97426f1a83 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/V1Permission.java @@ -0,0 +1,25 @@ +package sonia.scm.update.repository; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "permissions") +class V1Permission { + private boolean groupPermission; + private String name; + private String type; + + public boolean isGroupPermission() { + return groupPermission; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java b/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java new file mode 100644 index 0000000000..256e2ad397 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/V1Repository.java @@ -0,0 +1,92 @@ +package sonia.scm.update.repository; + +import sonia.scm.update.properties.V1Properties; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.List; + +@XmlAccessorType(XmlAccessType.FIELD) +@XmlRootElement(name = "repositories") +public class V1Repository { + private String contact; + private long creationDate; + private Long lastModified; + private String description; + private String id; + private String name; + private boolean isPublic; + private boolean archived; + private String type; + private List permissions; + private V1Properties properties; + + public V1Repository() { + } + + public V1Repository(String id, String type, String name) { + this.id = id; + this.type = type; + this.name = name; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public String getContact() { + return contact; + } + + public long getCreationDate() { + return creationDate; + } + + public Long getLastModified() { + return lastModified; + } + + public String getDescription() { + return description; + } + + public boolean isPublic() { + return isPublic; + } + + public boolean isArchived() { + return archived; + } + + public List getPermissions() { + return permissions; + } + + public V1Properties getProperties() { + return properties; + } + + @Override + public String toString() { + return "V1Repository{" + + ", contact='" + contact + '\'' + + ", creationDate=" + creationDate + + ", lastModified=" + lastModified + + ", description='" + description + '\'' + + ", id='" + id + '\'' + + ", name='" + name + '\'' + + ", isPublic=" + isPublic + + ", archived=" + archived + + ", type='" + type + '\'' + + '}'; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStep.java index 62902713f0..9df6f81440 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStep.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStep.java @@ -6,6 +6,7 @@ import sonia.scm.SCMContextProvider; import sonia.scm.migration.UpdateStep; import sonia.scm.plugin.Extension; import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver; +import sonia.scm.repository.xml.XmlRepositoryDAO; import sonia.scm.store.StoreConstants; import sonia.scm.version.Version; @@ -27,10 +28,12 @@ public class XmlRepositoryFileNameUpdateStep implements UpdateStep { private static final Logger LOG = LoggerFactory.getLogger(XmlRepositoryFileNameUpdateStep.class); private final SCMContextProvider contextProvider; + private final XmlRepositoryDAO repositoryDAO; @Inject - public XmlRepositoryFileNameUpdateStep(SCMContextProvider contextProvider) { + public XmlRepositoryFileNameUpdateStep(SCMContextProvider contextProvider, XmlRepositoryDAO repositoryDAO) { this.contextProvider = contextProvider; + this.repositoryDAO = repositoryDAO; } @Override @@ -41,6 +44,7 @@ public class XmlRepositoryFileNameUpdateStep implements UpdateStep { if (Files.exists(oldRepositoriesFile)) { LOG.info("moving old repositories database files to repository-paths file"); Files.move(oldRepositoriesFile, newRepositoryPathsFile); + repositoryDAO.refresh(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java index 34c08fb16b..ad20b59529 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java +++ b/scm-webapp/src/main/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStep.java @@ -28,11 +28,12 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; +import static java.util.Collections.emptyList; import static java.util.Optional.empty; import static java.util.Optional.of; import static sonia.scm.version.Version.parse; @@ -102,13 +103,30 @@ public class XmlRepositoryV1UpdateStep implements UpdateStep { JAXBContext jaxbContext = JAXBContext.newInstance(V1RepositoryDatabase.class); readV1Database(jaxbContext).ifPresent( v1Database -> { - v1Database.repositoryList.repositories.forEach(this::readMigrationStrategy); + v1Database.repositoryList.repositories.forEach(this::readMigrationEntry); v1Database.repositoryList.repositories.forEach(this::update); backupOldRepositoriesFile(); } ); } + public List getRepositoriesWithoutMigrationStrategies() { + if (!resolveV1File().exists()) { + LOG.info("no v1 repositories database file found"); + return emptyList(); + } + try { + JAXBContext jaxbContext = JAXBContext.newInstance(XmlRepositoryV1UpdateStep.V1RepositoryDatabase.class); + return readV1Database(jaxbContext) + .map(v1Database -> v1Database.repositoryList.repositories.stream()) + .orElse(Stream.empty()) + .filter(v1Repository -> !this.findMigrationStrategy(v1Repository).isPresent()) + .collect(Collectors.toList()); + } catch (JAXBException e) { + throw new UpdateException("could not read v1 repository database", e); + } + } + private void backupOldRepositoriesFile() { Path configDir = contextProvider.getBaseDirectory().toPath().resolve(StoreConstants.CONFIG_DIRECTORY_NAME); Path oldRepositoriesFile = configDir.resolve("repositories.xml"); @@ -122,61 +140,59 @@ public class XmlRepositoryV1UpdateStep implements UpdateStep { } private void update(V1Repository v1Repository) { - Path destination = handleDataDirectory(v1Repository); - Repository repository = new Repository( - v1Repository.id, - v1Repository.type, - getNamespace(v1Repository), - getName(v1Repository), - v1Repository.contact, - v1Repository.description, - createPermissions(v1Repository)); - LOG.info("creating new repository {} with id {} from old repository {} in directory {}", repository.getNamespaceAndName(), repository.getId(), v1Repository.name, destination); - repositoryDao.add(repository, destination); - propertyStore.put(v1Repository.id, v1Repository.properties); + RepositoryMigrationPlan.RepositoryMigrationEntry repositoryMigrationEntry = readMigrationEntry(v1Repository); + Optional destination = handleDataDirectory(v1Repository, repositoryMigrationEntry.getDataMigrationStrategy()); + LOG.info("using strategy {} to migrate repository {} with id {} using new namespace {} and name {}", + repositoryMigrationEntry.getDataMigrationStrategy().getClass(), + v1Repository.getName(), + v1Repository.getId(), + repositoryMigrationEntry.getNewNamespace(), + repositoryMigrationEntry.getNewName()); + destination.ifPresent( + newPath -> { + Repository repository = new Repository( + v1Repository.getId(), + v1Repository.getType(), + repositoryMigrationEntry.getNewNamespace(), + repositoryMigrationEntry.getNewName(), + v1Repository.getContact(), + v1Repository.getDescription(), + createPermissions(v1Repository)); + LOG.info("creating new repository {} with id {} from old repository {} in directory {}", repository.getNamespaceAndName(), repository.getId(), v1Repository.getName(), newPath); + repositoryDao.add(repository, newPath); + propertyStore.put(v1Repository.getId(), v1Repository.getProperties()); + } + ); } - private Path handleDataDirectory(V1Repository v1Repository) { - MigrationStrategy dataMigrationStrategy = readMigrationStrategy(v1Repository); - return dataMigrationStrategy.from(injector).migrate(v1Repository.id, v1Repository.name, v1Repository.type); + private Optional handleDataDirectory(V1Repository v1Repository, MigrationStrategy dataMigrationStrategy) { + return dataMigrationStrategy + .from(injector) + .migrate(v1Repository.getId(), v1Repository.getName(), v1Repository.getType()); } - private MigrationStrategy readMigrationStrategy(V1Repository v1Repository) { - return migrationStrategyDao.get(v1Repository.id) - .orElseThrow(() -> new IllegalStateException("no strategy found for repository with id " + v1Repository.id + " and name " + v1Repository.name)); + private RepositoryMigrationPlan.RepositoryMigrationEntry readMigrationEntry(V1Repository v1Repository) { + return findMigrationStrategy(v1Repository) + .orElseThrow(() -> new IllegalStateException("no strategy found for repository with id " + v1Repository.getId() + " and name " + v1Repository.getName())); + } + + private Optional findMigrationStrategy(V1Repository v1Repository) { + return migrationStrategyDao.get(v1Repository.getId()); } private RepositoryPermission[] createPermissions(V1Repository v1Repository) { - if (v1Repository.permissions == null) { + if (v1Repository.getPermissions() == null) { return new RepositoryPermission[0]; } - return v1Repository.permissions + return v1Repository.getPermissions() .stream() .map(this::createPermission) .toArray(RepositoryPermission[]::new); } private RepositoryPermission createPermission(V1Permission v1Permission) { - LOG.info("creating permission {} for {}", v1Permission.type, v1Permission.name); - return new RepositoryPermission(v1Permission.name, v1Permission.type, v1Permission.groupPermission); - } - - private String getNamespace(V1Repository v1Repository) { - String[] nameParts = getNameParts(v1Repository.name); - return nameParts.length > 1 ? nameParts[0] : v1Repository.type; - } - - private String getName(V1Repository v1Repository) { - String[] nameParts = getNameParts(v1Repository.name); - return nameParts.length == 1 ? nameParts[0] : concatPathElements(nameParts); - } - - private String concatPathElements(String[] nameParts) { - return Arrays.stream(nameParts).skip(1).collect(Collectors.joining("_")); - } - - private String[] getNameParts(String v1Name) { - return v1Name.split("/"); + LOG.info("creating permission {} for {}", v1Permission.getType(), v1Permission.getName()); + return new RepositoryPermission(v1Permission.getName(), v1Permission.getType(), v1Permission.isGroupPermission()); } private Optional readV1Database(JAXBContext jaxbContext) throws JAXBException { @@ -195,45 +211,6 @@ public class XmlRepositoryV1UpdateStep implements UpdateStep { ).toFile(); } - @XmlAccessorType(XmlAccessType.FIELD) - @XmlRootElement(name = "permissions") - private static class V1Permission { - private boolean groupPermission; - private String name; - private String type; - } - - @XmlAccessorType(XmlAccessType.FIELD) - @XmlRootElement(name = "repositories") - private static class V1Repository { - private String contact; - private long creationDate; - private Long lastModified; - private String description; - private String id; - private String name; - private boolean isPublic; - private boolean archived; - private String type; - private List permissions; - private V1Properties properties; - - @Override - public String toString() { - return "V1Repository{" + - ", contact='" + contact + '\'' + - ", creationDate=" + creationDate + - ", lastModified=" + lastModified + - ", description='" + description + '\'' + - ", id='" + id + '\'' + - ", name='" + name + '\'' + - ", isPublic=" + isPublic + - ", archived=" + archived + - ", type='" + type + '\'' + - '}'; - } - } - private static class RepositoryList { @XmlElement(name = "repository") private List repositories; diff --git a/scm-webapp/src/main/resources/templates/error.mustache b/scm-webapp/src/main/resources/templates/error.mustache new file mode 100644 index 0000000000..4b9b0a9086 --- /dev/null +++ b/scm-webapp/src/main/resources/templates/error.mustache @@ -0,0 +1,14 @@ +{{< layout}} + + {{$title}}SCM-Manager Error{{/title}} + + {{$content}} +

    An error occurred during SCM-Manager startup.

    + +
    +
    +        {{ error }}
    +      
    +
    + {{/content}} +{{/ layout}} diff --git a/scm-webapp/src/main/resources/templates/layout.mustache b/scm-webapp/src/main/resources/templates/layout.mustache new file mode 100644 index 0000000000..b656984eac --- /dev/null +++ b/scm-webapp/src/main/resources/templates/layout.mustache @@ -0,0 +1,34 @@ + + + + {{$title}}SCM-Manager{{/title}} + + + + + +
    + +
    +
    +
    +
    +
    SCM-Manager
    +
    +
    +
    +
    + +
    +
    +
    +

    {{$title}}SCM-Manager{{/title}}

    + {{$content}}{{/content}} +
    +
    +
    + +
    +{{$script}}{{/script}} + + diff --git a/scm-webapp/src/main/resources/templates/repository-migration-restart.mustache b/scm-webapp/src/main/resources/templates/repository-migration-restart.mustache new file mode 100644 index 0000000000..006cc77e2a --- /dev/null +++ b/scm-webapp/src/main/resources/templates/repository-migration-restart.mustache @@ -0,0 +1,68 @@ +{{ + + + + + + + + + + + +

    +{{/content}} + +{{$script}} + +{{/script}} + +{{/layout}} diff --git a/scm-webapp/src/main/resources/templates/repository-migration.mustache b/scm-webapp/src/main/resources/templates/repository-migration.mustache new file mode 100644 index 0000000000..9c76438667 --- /dev/null +++ b/scm-webapp/src/main/resources/templates/repository-migration.mustache @@ -0,0 +1,104 @@ +{{< layout}} + +{{$title}}SCM-Manager Migration{{/title}} + +{{$content}} +

    You have migrated from SCM-Manager v1 to SCM-Manager v2.

    + +

    + To migrate the existing repositories you have to specify a namespace and a name for each on them + as well as a migration strategy. +

    + +

    + The strategies are the following: +

    + + + {{#strategies}} + + + + + {{/strategies}} +
    {{name}}{{description}}
    + +
    + + {{#validationErrorsFound}} +
    Please correct the invalid namespaces or names below and try again.
    +
    + {{/validationErrorsFound}} + +
    + + + + + + + + + {{#repositories}} + + + + + + + + {{/repositories}} +
    Original nameTypeNew namespace + + New name + + Strategy + +
    Change all: +
    +
    + +
    +
    +
    + {{path}} + + {{type}} + + + + + +
    +
    + +
    +
    +
    + +
    +{{/content}} + +{{$script}} + +{{/script}} + +{{/ layout}} diff --git a/scm-webapp/src/main/resources/templates/repository-root.mustache b/scm-webapp/src/main/resources/templates/repository-root.mustache deleted file mode 100644 index 7e5cd5951e..0000000000 --- a/scm-webapp/src/main/resources/templates/repository-root.mustache +++ /dev/null @@ -1,102 +0,0 @@ - - - - - SCM-Manager support information - - - - -

    SCM-Manager Repositories

    - -
      - {{#repositories}} -
    • - {{name}} -
    • - {{/repositories}} -
    - - - diff --git a/scm-webapp/src/main/resources/templates/support.mustache b/scm-webapp/src/main/resources/templates/support.mustache deleted file mode 100644 index 822ac5e9d5..0000000000 --- a/scm-webapp/src/main/resources/templates/support.mustache +++ /dev/null @@ -1,150 +0,0 @@ - - - - - SCM-Manager support information - - - - -

    SCM-Manager support information

    - -

    Information for SCM-Manager support.

    - -

    Version

    - -
      -
    • Version: {{version.version}}
    • -
    • Stage: {{version.stage}}
    • -
    • StoreFactory: {{version.storeFactory}}
    • -
    - -

    Configuration

    - -
      -
    • Anonymous Access Enabled: {{configuration.anonymousAccessEnabled}}
    • -
    • Enable Proxy: {{configuration.enableProxy}}
    • -
    • Force Base Url: {{configuration.forceBaseUrl}}
    • -
    • Disable Grouping Grid: {{configuration.disableGroupingGrid}}
    • -
    • Enable Repository Archive: {{configuration.enableRepositoryArchive}}
    • -
    - -

    Installed Plugins

    - -
      - {{#pluginManager.installed}} -
    • {{id}}
    • - {{/pluginManager.installed}} -
    - -

    Runtime

    - -
      -
    • Free Memory: {{runtime.freeMemory}}
    • -
    • Total Memory: {{runtime.totalMemory}}
    • -
    • Max Memory: {{runtime.maxMemory}}
    • -
    • Available Processors: {{runtime.availableProcessors}}
    • -
    - -

    System

    - -
      -
    • OS: {{system.os}}
    • -
    • Architecture: {{system.arch}}
    • -
    • ServletContainer: {{system.container}}
    • -
    • Java: {{system.java}}
    • -
    • Local: {{system.locale}}
    • -
    • TimeZone: {{system.timeZone}}
    • -
    - -

    Repository Handlers

    - -
      - {{#repositoryHandlers}} -
    • {{type.displayName}}/{{type.name}} ({{versionInformation}})
    • - {{/repositoryHandlers}} -
    - - - diff --git a/scm-webapp/src/main/resources/templates/too-old.mustache b/scm-webapp/src/main/resources/templates/too-old.mustache new file mode 100644 index 0000000000..2ff578b263 --- /dev/null +++ b/scm-webapp/src/main/resources/templates/too-old.mustache @@ -0,0 +1,14 @@ +{{< layout}} + + {{$title}}SCM-Manager Error{{/title}} + + {{$content}} +

    An error occurred during SCM-Manager startup.

    + +

    + We cannot migrate your SCM-Manager 1 installation, + because the version is too old.
    + Please migrate to version 1.60 or newer, before migration to 2.x. +

    + {{/content}} +{{/ layout}} diff --git a/scm-webapp/src/main/webapp/error.mustache b/scm-webapp/src/main/webapp/error.mustache deleted file mode 100644 index 7a726a2664..0000000000 --- a/scm-webapp/src/main/webapp/error.mustache +++ /dev/null @@ -1,102 +0,0 @@ - - - - - SCM-Manager Error - - - - -

    SCM-Manager Error

    - -

    - There is an error occurred during SCM-Manager startup. -

    - -
    -      {{error}}
    -    
    - - - diff --git a/scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java new file mode 100644 index 0000000000..9dfebdc571 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewServletTest.java @@ -0,0 +1,90 @@ +package sonia.scm.boot; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.template.Template; +import sonia.scm.template.TemplateEngine; +import sonia.scm.template.TemplateEngineFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SingleViewServletTest { + + @Mock + private TemplateEngineFactory templateEngineFactory; + + @Mock + private TemplateEngine templateEngine; + + @Mock + private Template template; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private PrintWriter writer; + + @Mock + private ViewController controller; + + @Test + void shouldRenderTheTemplateOnGet() throws IOException { + prepareTemplate("/template"); + doReturn(new View(200, "hello")).when(controller).createView(request); + + new SingleViewServlet(templateEngineFactory, controller).doGet(request, response); + + verifyResponse(200, "hello"); + } + + private void verifyResponse(int sc, Object model) throws IOException { + verify(response).setStatus(sc); + verify(response).setContentType("text/html"); + verify(response).setCharacterEncoding("UTF-8"); + + verify(template).execute(writer, model); + } + + @Test + void shouldRenderTheTemplateOnPost() throws IOException { + prepareTemplate("/template"); + + doReturn(new View(201, "hello")).when(controller).createView(request); + + new SingleViewServlet(templateEngineFactory, controller).doPost(request, response); + + verifyResponse(201, "hello"); + } + + @Test + void shouldThrowIllegalStateExceptionOnIOException() throws IOException { + doReturn("/template").when(controller).getTemplate(); + doReturn(templateEngine).when(templateEngineFactory).getEngineByExtension("/template"); + doThrow(IOException.class).when(templateEngine).getTemplate("/template"); + + assertThrows(IllegalStateException.class, () -> new SingleViewServlet(templateEngineFactory, controller)); + } + + private void prepareTemplate(String templatePath) throws IOException { + doReturn(templateEngine).when(templateEngineFactory).getEngineByExtension(templatePath); + doReturn(template).when(templateEngine).getTemplate(templatePath); + doReturn(templatePath).when(controller).getTemplate(); + + doReturn(writer).when(response).getWriter(); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java new file mode 100644 index 0000000000..f520aea504 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/SingleViewTest.java @@ -0,0 +1,111 @@ +package sonia.scm.boot; + +import com.google.inject.Injector; +import com.google.inject.servlet.GuiceFilter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SingleViewTest { + + @Mock + private ServletContext servletContext; + + @Mock + private HttpServletRequest request; + + @Captor + private ArgumentCaptor captor; + + private GuiceFilter guiceFilter; + + @BeforeEach + void setUpGuiceFilter() throws ServletException { + guiceFilter = new GuiceFilter(); + FilterConfig config = mock(FilterConfig.class); + doReturn(servletContext).when(config).getServletContext(); + guiceFilter.init(config); + } + + @AfterEach + void tearDownGuiceFilter() { + guiceFilter.destroy(); + } + + @Test + void shouldCreateViewControllerForView() { + ServletContextListener listener = SingleView.view("/my-template", 409); + when(request.getContextPath()).thenReturn("/scm"); + + ViewController instance = findViewController(listener); + assertThat(instance.getTemplate()).isEqualTo("/my-template"); + + View view = instance.createView(request); + assertThat(view.getStatusCode()).isEqualTo(409); + } + + @Test + void shouldCreateViewControllerForError() { + ServletContextListener listener = SingleView.error(new IOException("awesome io")); + when(request.getContextPath()).thenReturn("/scm"); + + ViewController instance = findViewController(listener); + assertErrorViewController(instance, "awesome io"); + } + + @Test + void shouldBindServlets() { + ServletContextListener listener = SingleView.error(new IOException("awesome io")); + Injector injector = findInjector(listener); + + assertThat(injector.getInstance(StaticResourceServlet.class)).isNotNull(); + assertThat(injector.getInstance(SingleViewServlet.class)).isNotNull(); + } + + @SuppressWarnings("unchecked") + private void assertErrorViewController(ViewController instance, String contains) { + assertThat(instance.getTemplate()).isEqualTo("/templates/error.mustache"); + + View view = instance.createView(request); + assertThat(view.getStatusCode()).isEqualTo(500); + assertThat(view.getModel()).isInstanceOfSatisfying(Map.class, map -> { + assertThat(map).containsEntry("contextPath", "/scm"); + String error = (String) map.get("error"); + assertThat(error).contains(contains); + } + ); + } + + private ViewController findViewController(ServletContextListener listener) { + Injector injector = findInjector(listener); + return injector.getInstance(ViewController.class); + } + + private Injector findInjector(ServletContextListener listener) { + listener.contextInitialized(new ServletContextEvent(servletContext)); + + verify(servletContext).setAttribute(anyString(), captor.capture()); + + return captor.getValue(); + } + + +} diff --git a/scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java b/scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java new file mode 100644 index 0000000000..a350f4d9f3 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/StaticResourceServletTest.java @@ -0,0 +1,61 @@ +package sonia.scm.boot; + +import com.google.common.io.Resources; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.ServletContext; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URL; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class StaticResourceServletTest { + + @Mock + private HttpServletRequest request; + + @Mock + private ServletOutputStream stream; + + @Mock + private HttpServletResponse response; + + @Mock + private ServletContext context; + + @Test + void shouldServeResource() throws IOException { + doReturn("/scm").when(request).getContextPath(); + doReturn("/scm/resource.txt").when(request).getRequestURI(); + doReturn(context).when(request).getServletContext(); + URL resource = Resources.getResource("sonia/scm/boot/resource.txt"); + doReturn(resource).when(context).getResource("/resource.txt"); + doReturn(stream).when(response).getOutputStream(); + + StaticResourceServlet servlet = new StaticResourceServlet(); + servlet.doGet(request, response); + + verify(response).setStatus(HttpServletResponse.SC_OK); + } + + @Test + void shouldReturnNotFound() throws IOException { + doReturn("/scm").when(request).getContextPath(); + doReturn("/scm/resource.txt").when(request).getRequestURI(); + doReturn(context).when(request).getServletContext(); + + StaticResourceServlet servlet = new StaticResourceServlet(); + servlet.doGet(request, response); + + verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/boot/VersionsTest.java b/scm-webapp/src/test/java/sonia/scm/boot/VersionsTest.java new file mode 100644 index 0000000000..e5aa8fe3d1 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/boot/VersionsTest.java @@ -0,0 +1,86 @@ +package sonia.scm.boot; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junitpioneer.jupiter.TempDirectory; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; + +@ExtendWith({MockitoExtension.class, TempDirectory.class}) +class VersionsTest { + + @Mock + private SCMContextProvider contextProvider; + + @InjectMocks + private Versions versions; + + @Test + void shouldReturnTrueForVersionsPreviousTo160(@TempDirectory.TempDir Path directory) throws IOException { + setVersion(directory, "1.59"); + assertThat(versions.isPreviousVersionTooOld()).isTrue(); + + setVersion(directory, "1.12"); + assertThat(versions.isPreviousVersionTooOld()).isTrue(); + } + + @Test + void shouldReturnFalseForVersion160(@TempDirectory.TempDir Path directory) throws IOException { + setVersion(directory, "1.60"); + assertThat(versions.isPreviousVersionTooOld()).isFalse(); + } + + @Test + void shouldNotFailIfVersionContainsLineBreak(@TempDirectory.TempDir Path directory) throws IOException { + setVersion(directory, "1.59\n"); + assertThat(versions.isPreviousVersionTooOld()).isTrue(); + } + + @Test + void shouldReturnFalseForVersionsNewerAs160(@TempDirectory.TempDir Path directory) throws IOException { + setVersion(directory, "1.61"); + assertThat(versions.isPreviousVersionTooOld()).isFalse(); + + setVersion(directory, "1.82"); + assertThat(versions.isPreviousVersionTooOld()).isFalse(); + } + + @Test + void shouldReturnFalseForNonExistingVersionFile(@TempDirectory.TempDir Path directory) { + setVersionFile(directory.resolve("version.txt")); + assertThat(versions.isPreviousVersionTooOld()).isFalse(); + } + + @Test + void shouldWriteNewVersion(@TempDirectory.TempDir Path directory) { + Path config = directory.resolve("config"); + doReturn(config).when(contextProvider).resolve(Paths.get("config")); + doReturn("2.0.0").when(contextProvider).getVersion(); + + versions.writeNewVersion(); + + Path versionFile = config.resolve("version.txt"); + assertThat(versionFile).exists().hasContent("2.0.0"); + } + + private void setVersion(Path directory, String version) throws IOException { + Path file = directory.resolve("version.txt"); + Files.write(file, version.getBytes(StandardCharsets.UTF_8)); + setVersionFile(file); + } + + private void setVersionFile(Path file) { + doReturn(file).when(contextProvider).resolve(Paths.get("config", "version.txt")); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/template/MustacheTemplateEngineTest.java b/scm-webapp/src/test/java/sonia/scm/template/MustacheTemplateEngineTest.java index be0c33f098..131db6e44d 100644 --- a/scm-webapp/src/test/java/sonia/scm/template/MustacheTemplateEngineTest.java +++ b/scm-webapp/src/test/java/sonia/scm/template/MustacheTemplateEngineTest.java @@ -35,16 +35,21 @@ package sonia.scm.template; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.collect.ImmutableMap; +import org.assertj.core.api.Assertions; +import org.junit.Test; import sonia.scm.plugin.PluginLoader; -import static org.mockito.Mockito.*; +import javax.servlet.ServletContext; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; //~--- JDK imports ------------------------------------------------------------ -import java.io.InputStream; - -import javax.servlet.ServletContext; - /** * * @author Sebastian Sdorra @@ -68,7 +73,10 @@ public class MustacheTemplateEngineTest extends TemplateEngineTestBase when(loader.getUberClassLoader()).thenReturn( Thread.currentThread().getContextClassLoader()); - return new MustacheTemplateEngine(context, loader); + MustacheTemplateEngine.PluginLoaderHolder holder = new MustacheTemplateEngine.PluginLoaderHolder(); + holder.pluginLoader = loader; + + return new MustacheTemplateEngine(context, holder); } //~--- get methods ---------------------------------------------------------- @@ -116,4 +124,18 @@ public class MustacheTemplateEngineTest extends TemplateEngineTestBase return MustacheTemplateEngineTest.class.getResourceAsStream( "/sonia/scm/template/".concat(resource).concat(".mustache")); } + + @Test + public void testCreateEngineWithoutPluginLoader() throws IOException { + ServletContext context = mock(ServletContext.class); + MustacheTemplateEngine.PluginLoaderHolder holder = new MustacheTemplateEngine.PluginLoaderHolder(); + MustacheTemplateEngine engine = new MustacheTemplateEngine(context, holder); + + Template template = engine.getTemplate(getTemplateResource()); + + StringWriter writer = new StringWriter(); + template.execute(writer, ImmutableMap.of("name", "World")); + + Assertions.assertThat(writer.toString()).isEqualTo("Hello World!"); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/update/MigrationWizardServletTest.java b/scm-webapp/src/test/java/sonia/scm/update/MigrationWizardServletTest.java new file mode 100644 index 0000000000..fed4a3b6c8 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/update/MigrationWizardServletTest.java @@ -0,0 +1,224 @@ +package sonia.scm.update; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.update.repository.MigrationStrategy; +import sonia.scm.update.repository.MigrationStrategyDao; +import sonia.scm.update.repository.V1Repository; +import sonia.scm.update.repository.XmlRepositoryV1UpdateStep; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MigrationWizardServletTest { + + @Mock + XmlRepositoryV1UpdateStep updateStep; + @Mock + MigrationStrategyDao migrationStrategyDao; + + @Mock + HttpServletRequest request; + @Mock + HttpServletResponse response; + + String renderedTemplateName; + Map renderedModel; + + MigrationWizardServlet servlet; + + @BeforeEach + void initServlet() { + servlet = new MigrationWizardServlet(updateStep, migrationStrategyDao) { + @Override + void respondWithTemplate(HttpServletResponse resp, Map model, String templateName) { + renderedTemplateName = templateName; + renderedModel = model; + } + }; + } + + @Test + void shouldUseRepositoryTypeAsNamespaceForNamesWithSingleElement() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "simple")) + ); + + servlet.doGet(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("namespace") + .contains("git"); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("name") + .contains("simple"); + } + + @Test + void shouldUseDirectoriesForNamespaceAndNameForNamesWithTwoElements() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "two/dirs")) + ); + + servlet.doGet(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("namespace") + .contains("two"); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("name") + .contains("dirs"); + } + + @Test + void shouldUseDirectoriesForNamespaceAndConcatenatedNameForNamesWithMoreThanTwoElements() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "more/than/two/dirs")) + ); + + servlet.doGet(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("namespace") + .contains("more"); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("name") + .contains("than_two_dirs"); + } + + @Test + void shouldUseTypeAndNameAsPath() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + + servlet.doGet(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("path") + .contains("git/name"); + } + + @Test + void shouldKeepId() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + + servlet.doGet(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("id") + .contains("id"); + } + + @Test + void shouldNotBeInvalidAtFirstRequest() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + + servlet.doGet(request, response); + + assertThat(renderedModel.get("validationErrorsFound")).isEqualTo(false); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("namespaceInvalid") + .contains(false); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("nameInvalid") + .contains(false); + } + + @Test + void shouldValidateNamespaceAndNameOnPost() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + doReturn("invalid namespace").when(request).getParameter("namespace-id"); + doReturn("invalid name").when(request).getParameter("name-id"); + doReturn("COPY").when(request).getParameter("strategy-id"); + + servlet.doPost(request, response); + + assertThat(renderedModel.get("validationErrorsFound")).isEqualTo(true); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("namespaceInvalid") + .contains(true); + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("nameInvalid") + .contains(true); + } + + @Test + void shouldKeepSelectedMigrationStrategy() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + + doReturn("we need an").when(request).getParameter("namespace-id"); + doReturn("error for this test").when(request).getParameter("name-id"); + doReturn("INLINE").when(request).getParameter("strategy-id"); + + servlet.doPost(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("selectedStrategy") + .contains(MigrationStrategy.INLINE); + } + + @Test + void shouldUseCopyWithoutMigrationStrategy() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + + doReturn("we need an").when(request).getParameter("namespace-id"); + doReturn("error for this test").when(request).getParameter("name-id"); + doReturn("").when(request).getParameter("strategy-id"); + + servlet.doPost(request, response); + + assertThat(renderedModel.get("repositories")) + .asList() + .extracting("selectedStrategy") + .contains(MigrationStrategy.COPY); + } + + @Test + void shouldStoreValidMigration() { + when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn( + Collections.singletonList(new V1Repository("id", "git", "name")) + ); + doReturn("namespace").when(request).getParameter("namespace-id"); + doReturn("name").when(request).getParameter("name-id"); + doReturn("COPY").when(request).getParameter("strategy-id"); + + servlet.doPost(request, response); + + verify(migrationStrategyDao).set("id", MigrationStrategy.COPY, "namespace", "name"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/CopyMigrationStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/CopyMigrationStrategyTest.java index b40283ae79..d3f18a1ff7 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/CopyMigrationStrategyTest.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/CopyMigrationStrategyTest.java @@ -43,18 +43,18 @@ class CopyMigrationStrategyTest { void mockLocationResolver(@TempDirectory.TempDir Path tempDir) { RepositoryLocationResolver.RepositoryLocationResolverInstance instanceMock = mock(RepositoryLocationResolver.RepositoryLocationResolverInstance.class); when(locationResolver.forClass(Path.class)).thenReturn(instanceMock); - when(instanceMock.getLocation(anyString())).thenAnswer(invocation -> tempDir.resolve((String) invocation.getArgument(0))); + when(instanceMock.createLocation(anyString())).thenAnswer(invocation -> tempDir.resolve((String) invocation.getArgument(0))); } @Test void shouldUseStandardDirectory(@TempDirectory.TempDir Path tempDir) { - Path target = new CopyMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + Path target = new CopyMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git").get(); assertThat(target).isEqualTo(tempDir.resolve("b4f-a9f0-49f7-ad1f-37d3aae1c55f")); } @Test void shouldCopyDataDirectory(@TempDirectory.TempDir Path tempDir) { - Path target = new CopyMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + Path target = new CopyMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git").get(); assertThat(target.resolve("data")).exists(); Path originalDataDir = tempDir .resolve("repositories") diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/InlineMigrationStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/InlineMigrationStrategyTest.java index 6abddae3fb..2b97c1d79b 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/InlineMigrationStrategyTest.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/InlineMigrationStrategyTest.java @@ -7,11 +7,14 @@ import org.junitpioneer.jupiter.TempDirectory; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.SCMContextProvider; +import sonia.scm.repository.RepositoryLocationResolver; +import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver; import java.io.IOException; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(TempDirectory.class) @@ -20,9 +23,14 @@ class InlineMigrationStrategyTest { @Mock SCMContextProvider contextProvider; + @Mock + PathBasedRepositoryLocationResolver locationResolver; + @Mock + RepositoryLocationResolver.RepositoryLocationResolverInstance locationResolverInstance; @BeforeEach void mockContextProvider(@TempDirectory.TempDir Path tempDir) { + when(locationResolver.forClass(Path.class)).thenReturn(locationResolverInstance); when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); } @@ -33,13 +41,14 @@ class InlineMigrationStrategyTest { @Test void shouldUseExistingDirectory(@TempDirectory.TempDir Path tempDir) { - Path target = new InlineMigrationStrategy(contextProvider).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + Path target = new InlineMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git").get(); assertThat(target).isEqualTo(resolveOldDirectory(tempDir)); + verify(locationResolverInstance).setLocation("b4f-a9f0-49f7-ad1f-37d3aae1c55f", target); } @Test void shouldMoveDataDirectory(@TempDirectory.TempDir Path tempDir) { - new InlineMigrationStrategy(contextProvider).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + new InlineMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); assertThat(resolveOldDirectory(tempDir).resolve("data")).exists(); } diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/MigrateVerbsToPermissionRolesTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/MigrateVerbsToPermissionRolesTest.java new file mode 100644 index 0000000000..8d4ceb1a14 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/MigrateVerbsToPermissionRolesTest.java @@ -0,0 +1,76 @@ +package sonia.scm.update.repository; + +import com.google.common.io.Resources; +import org.assertj.core.api.Assertions; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.RepositoryRole; +import sonia.scm.repository.xml.SingleRepositoryUpdateProcessor; +import sonia.scm.security.SystemRepositoryPermissionProvider; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.function.BiConsumer; + +import static java.util.Arrays.asList; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(TempDirectory.class) +class MigrateVerbsToPermissionRolesTest { + + private static final String EXISTING_REPOSITORY_ID = "id"; + + @Mock + private SingleRepositoryUpdateProcessor singleRepositoryUpdateProcessor; + @Mock + private SystemRepositoryPermissionProvider systemRepositoryPermissionProvider; + + @InjectMocks + private MigrateVerbsToPermissionRoles migration; + + @BeforeEach + void init(@TempDirectory.TempDir Path tempDir) throws IOException { + URL metadataUrl = Resources.getResource("sonia/scm/update/repository/metadataWithoutRoles.xml"); + Files.copy(metadataUrl.openStream(), tempDir.resolve("metadata.xml")); + doAnswer(invocation -> { + ((BiConsumer) invocation.getArgument(0)).accept(EXISTING_REPOSITORY_ID, tempDir); + return null; + }).when(singleRepositoryUpdateProcessor).doUpdate(any()); + when(systemRepositoryPermissionProvider.availableRoles()).thenReturn(Collections.singletonList(new RepositoryRole("ROLE", asList("read", "write"), ""))); + } + + @Test + void shouldUpdateToRolesIfPossible(@TempDirectory.TempDir Path tempDir) throws IOException { + migration.doUpdate(); + + List newMetadata = Files.readAllLines(tempDir.resolve("metadata.xml")); + Assertions.assertThat(newMetadata.stream().map(String::trim)). + containsSubsequence( + "false", + "user", + "ROLE" + ) + .containsSubsequence( + "true", + "group", + "special" + ) + .doesNotContain( + "read", + "write" + ); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyDaoTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyDaoTest.java index e3fd4457b6..5829f5b98f 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyDaoTest.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyDaoTest.java @@ -11,8 +11,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.SCMContextProvider; import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.store.JAXBConfigurationStoreFactory; -import sonia.scm.update.repository.MigrationStrategy; -import sonia.scm.update.repository.MigrationStrategyDao; import javax.xml.bind.JAXBException; import java.nio.file.Path; @@ -37,23 +35,31 @@ class MigrationStrategyDaoTest { } @Test - void shouldReturnEmptyOptionalWhenStoreIsEmpty() throws JAXBException { + void shouldReturnEmptyOptionalWhenStoreIsEmpty() { MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); - Optional strategy = dao.get("any"); + Optional entry = dao.get("any"); - Assertions.assertThat(strategy).isEmpty(); + Assertions.assertThat(entry).isEmpty(); } @Test - void shouldReturnNewValue() throws JAXBException { + void shouldReturnNewValue() { MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); - dao.set("id", INLINE); + dao.set("id", INLINE, "space", "name"); - Optional strategy = dao.get("id"); + Optional entry = dao.get("id"); - Assertions.assertThat(strategy).contains(INLINE); + Assertions.assertThat(entry) + .map(RepositoryMigrationPlan.RepositoryMigrationEntry::getDataMigrationStrategy) + .contains(INLINE); + Assertions.assertThat(entry) + .map(RepositoryMigrationPlan.RepositoryMigrationEntry::getNewNamespace) + .contains("space"); + Assertions.assertThat(entry) + .map(RepositoryMigrationPlan.RepositoryMigrationEntry::getNewName) + .contains("name"); } @Nested @@ -62,16 +68,24 @@ class MigrationStrategyDaoTest { void initExistingDatabase() throws JAXBException { MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); - dao.set("id", INLINE); + dao.set("id", INLINE, "space", "name"); } @Test void shouldFindExistingValue() throws JAXBException { MigrationStrategyDao dao = new MigrationStrategyDao(storeFactory); - Optional strategy = dao.get("id"); + Optional entry = dao.get("id"); - Assertions.assertThat(strategy).contains(INLINE); + Assertions.assertThat(entry) + .map(RepositoryMigrationPlan.RepositoryMigrationEntry::getDataMigrationStrategy) + .contains(INLINE); + Assertions.assertThat(entry) + .map(RepositoryMigrationPlan.RepositoryMigrationEntry::getNewNamespace) + .contains("space"); + Assertions.assertThat(entry) + .map(RepositoryMigrationPlan.RepositoryMigrationEntry::getNewName) + .contains("name"); } } } diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyMock.java b/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyMock.java index e0018f584f..c04c9477bb 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyMock.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/MigrationStrategyMock.java @@ -3,10 +3,13 @@ package sonia.scm.update.repository; import com.google.inject.Injector; import sonia.scm.update.repository.MigrationStrategy.Instance; +import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; +import static java.util.Optional.of; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -20,6 +23,13 @@ class MigrationStrategyMock { .thenAnswer( invocationOnMock -> mocks.computeIfAbsent(invocationOnMock.getArgument(0), key -> mock((Class) key)) ); + + for (MigrationStrategy strategy : MigrationStrategy.values()) { + MigrationStrategy.Instance strategyMock = mock(strategy.getImplementationClass()); + when(strategyMock.migrate(any(), any(), any())).thenReturn(of(Paths.get(""))); + lenient().when(mock.getInstance((Class) strategy.getImplementationClass())).thenReturn(strategyMock); + } + return mock; } } diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/MoveMigrationStrategyTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/MoveMigrationStrategyTest.java index b55315d85f..fa58eb8ea1 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/MoveMigrationStrategyTest.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/MoveMigrationStrategyTest.java @@ -40,18 +40,18 @@ class MoveMigrationStrategyTest { void mockLocationResolver(@TempDirectory.TempDir Path tempDir) { RepositoryLocationResolver.RepositoryLocationResolverInstance instanceMock = mock(RepositoryLocationResolver.RepositoryLocationResolverInstance.class); when(locationResolver.forClass(Path.class)).thenReturn(instanceMock); - when(instanceMock.getLocation(anyString())).thenAnswer(invocation -> tempDir.resolve((String) invocation.getArgument(0))); + when(instanceMock.createLocation(anyString())).thenAnswer(invocation -> tempDir.resolve((String) invocation.getArgument(0))); } @Test void shouldUseStandardDirectory(@TempDirectory.TempDir Path tempDir) { - Path target = new MoveMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + Path target = new MoveMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git").get(); assertThat(target).isEqualTo(tempDir.resolve("b4f-a9f0-49f7-ad1f-37d3aae1c55f")); } @Test void shouldMoveDataDirectory(@TempDirectory.TempDir Path tempDir) { - Path target = new MoveMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git"); + Path target = new MoveMigrationStrategy(contextProvider, locationResolver).migrate("b4f-a9f0-49f7-ad1f-37d3aae1c55f", "some/more/directories/than/one", "git").get(); assertThat(target.resolve("data")).exists(); Path originalDataDir = tempDir .resolve("repositories") diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStepTest.java index 51be47fc82..658330956d 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStepTest.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryFileNameUpdateStepTest.java @@ -7,8 +7,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junitpioneer.jupiter.TempDirectory; import sonia.scm.SCMContextProvider; import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver; +import sonia.scm.repository.xml.XmlRepositoryDAO; -import javax.xml.bind.JAXBException; import java.io.IOException; import java.net.URL; import java.nio.file.Files; @@ -16,12 +16,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(TempDirectory.class) class XmlRepositoryFileNameUpdateStepTest { SCMContextProvider contextProvider = mock(SCMContextProvider.class); + XmlRepositoryDAO repositoryDAO = mock(XmlRepositoryDAO.class); @BeforeEach void mockScmHome(@TempDirectory.TempDir Path tempDir) { @@ -29,8 +31,8 @@ class XmlRepositoryFileNameUpdateStepTest { } @Test - void shouldCopyRepositoriesFileToRepositoryPathsFile(@TempDirectory.TempDir Path tempDir) throws JAXBException, IOException { - XmlRepositoryFileNameUpdateStep updateStep = new XmlRepositoryFileNameUpdateStep(contextProvider); + void shouldCopyRepositoriesFileToRepositoryPathsFile(@TempDirectory.TempDir Path tempDir) throws IOException { + XmlRepositoryFileNameUpdateStep updateStep = new XmlRepositoryFileNameUpdateStep(contextProvider, repositoryDAO); URL url = Resources.getResource("sonia/scm/update/repository/formerV2RepositoryFile.xml"); Path configDir = tempDir.resolve("config"); Files.createDirectories(configDir); @@ -40,5 +42,6 @@ class XmlRepositoryFileNameUpdateStepTest { assertThat(configDir.resolve(PathBasedRepositoryLocationResolver.STORE_NAME + ".xml")).exists(); assertThat(configDir.resolve("repositories.xml")).doesNotExist(); + verify(repositoryDAO).refresh(); } } diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStepTest.java index 2102d296ea..6c977a3cb6 100644 --- a/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStepTest.java +++ b/scm-webapp/src/test/java/sonia/scm/update/repository/XmlRepositoryV1UpdateStepTest.java @@ -11,6 +11,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.xml.XmlRepositoryDAO; @@ -33,11 +34,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static sonia.scm.update.repository.MigrationStrategy.COPY; -import static sonia.scm.update.repository.MigrationStrategy.INLINE; import static sonia.scm.update.repository.MigrationStrategy.MOVE; @ExtendWith(MockitoExtension.class) @@ -89,9 +89,14 @@ class XmlRepositoryV1UpdateStepTest { @BeforeEach void createMigrationPlan() { - lenient().when(migrationStrategyDao.get("3b91caa5-59c3-448f-920b-769aaa56b761")).thenReturn(of(MOVE)); - lenient().when(migrationStrategyDao.get("c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f")).thenReturn(of(COPY)); - lenient().when(migrationStrategyDao.get("454972da-faf9-4437-b682-dc4a4e0aa8eb")).thenReturn(of(INLINE)); + Answer planAnswer = invocation -> { + String id = invocation.getArgument(0).toString(); + return of(new RepositoryMigrationPlan.RepositoryMigrationEntry(id, MOVE, "namespace-" + id, "name-" + id)); + }; + + lenient().when(migrationStrategyDao.get("3b91caa5-59c3-448f-920b-769aaa56b761")).thenAnswer(planAnswer); + lenient().when(migrationStrategyDao.get("c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f")).thenAnswer(planAnswer); + lenient().when(migrationStrategyDao.get("454972da-faf9-4437-b682-dc4a4e0aa8eb")).thenAnswer(planAnswer); } @Test @@ -104,56 +109,20 @@ class XmlRepositoryV1UpdateStepTest { void shouldMapAttributes() throws JAXBException { updateStep.doUpdate(); - Optional repository = findByNamespace("git"); + Optional repository = findByNamespace("namespace-3b91caa5-59c3-448f-920b-769aaa56b761"); assertThat(repository) .get() .hasFieldOrPropertyWithValue("type", "git") .hasFieldOrPropertyWithValue("contact", "arthur@dent.uk") - .hasFieldOrPropertyWithValue("description", "A simple repository without directories."); - } - - @Test - void shouldUseRepositoryTypeAsNamespaceForNamesWithSingleElement() throws JAXBException { - updateStep.doUpdate(); - - Optional repository = findByNamespace("git"); - - assertThat(repository) - .get() - .hasFieldOrPropertyWithValue("namespace", "git") - .hasFieldOrPropertyWithValue("name", "simple"); - } - - @Test - void shouldUseDirectoriesForNamespaceAndNameForNamesWithTwoElements() throws JAXBException { - updateStep.doUpdate(); - - Optional repository = findByNamespace("one"); - - assertThat(repository) - .get() - .hasFieldOrPropertyWithValue("namespace", "one") - .hasFieldOrPropertyWithValue("name", "directory"); - } - - @Test - void shouldUseDirectoriesForNamespaceAndConcatenatedNameForNamesWithMoreThanTwoElements() throws JAXBException { - updateStep.doUpdate(); - - Optional repository = findByNamespace("some"); - - assertThat(repository) - .get() - .hasFieldOrPropertyWithValue("namespace", "some") - .hasFieldOrPropertyWithValue("name", "more_directories_than_one"); + .hasFieldOrPropertyWithValue("description", "A repository with two folders."); } @Test void shouldMapPermissions() throws JAXBException { updateStep.doUpdate(); - Optional repository = findByNamespace("git"); + Optional repository = findByNamespace("namespace-454972da-faf9-4437-b682-dc4a4e0aa8eb"); assertThat(repository.get().getPermissions()) .hasSize(3) @@ -176,14 +145,27 @@ class XmlRepositoryV1UpdateStepTest { @Test void shouldUseDirectoryFromStrategy(@TempDirectory.TempDir Path tempDir) throws JAXBException { Path targetDir = tempDir.resolve("someDir"); - MigrationStrategy.Instance strategyMock = injectorMock.getInstance(InlineMigrationStrategy.class); - when(strategyMock.migrate("454972da-faf9-4437-b682-dc4a4e0aa8eb", "simple", "git")).thenReturn(targetDir); + MigrationStrategy.Instance strategyMock = injectorMock.getInstance(MoveMigrationStrategy.class); + when(strategyMock.migrate("454972da-faf9-4437-b682-dc4a4e0aa8eb", "simple", "git")).thenReturn(of(targetDir)); updateStep.doUpdate(); assertThat(locationCaptor.getAllValues()).contains(targetDir); } + @Test + void shouldSkipWhenStrategyGivesNoNewPath() throws JAXBException { + for (MigrationStrategy strategy : MigrationStrategy.values()) { + MigrationStrategy.Instance strategyMock = mock(strategy.getImplementationClass()); + lenient().when(strategyMock.migrate(any(), any(), any())).thenReturn(empty()); + lenient().when(injectorMock.getInstance((Class) strategy.getImplementationClass())).thenReturn(strategyMock); + } + + updateStep.doUpdate(); + + assertThat(locationCaptor.getAllValues()).isEmpty(); + } + @Test void shouldFailForMissingMigrationStrategy() { lenient().when(migrationStrategyDao.get("c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f")).thenReturn(empty()); @@ -221,6 +203,25 @@ class XmlRepositoryV1UpdateStepTest { assertThat(tempDir.resolve("config").resolve("repositories.xml.v1.backup")).doesNotExist(); } + @Test + void shouldGetNoMissingStrategiesWithFormerV2DatabaseFile(@TempDirectory.TempDir Path tempDir) throws IOException { + createFormerV2RepositoriesFile(tempDir); + + assertThat(updateStep.getRepositoriesWithoutMigrationStrategies()).isEmpty(); + } + + @Test + void shouldFindMissingStrategies(@TempDirectory.TempDir Path tempDir) throws IOException { + V1RepositoryFileSystem.createV1Home(tempDir); + + assertThat(updateStep.getRepositoriesWithoutMigrationStrategies()) + .extracting("id") + .contains( + "3b91caa5-59c3-448f-920b-769aaa56b761", + "c1597b4f-a9f0-49f7-ad1f-37d3aae1c55f", + "454972da-faf9-4437-b682-dc4a4e0aa8eb"); + } + private void createFormerV2RepositoriesFile(@TempDirectory.TempDir Path tempDir) throws IOException { URL url = Resources.getResource("sonia/scm/update/repository/formerV2RepositoryFile.xml"); Path configDir = tempDir.resolve("config"); diff --git a/scm-webapp/src/test/resources/sonia/scm/boot/resource.txt b/scm-webapp/src/test/resources/sonia/scm/boot/resource.txt new file mode 100644 index 0000000000..c4a1132fa5 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/boot/resource.txt @@ -0,0 +1 @@ +Resource for testing diff --git a/scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithoutRoles.xml b/scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithoutRoles.xml new file mode 100644 index 0000000000..4716943f47 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/update/repository/metadataWithoutRoles.xml @@ -0,0 +1,25 @@ + + + + ich@du.er + 1557729536519 + + B3RQKYNzo2 + 1557825677782 + scmadmin + git + + false + user + read + write + + + true + group + special + + false + false + git +