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 bfb59f9f35..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,6 +1,7 @@ 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; @@ -8,8 +9,6 @@ import sonia.scm.store.StoreConstants; import javax.inject.Inject; import javax.inject.Singleton; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; import java.util.Map; @@ -36,6 +35,7 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation private final SCMContextProvider contextProvider; private final InitialRepositoryLocationResolver initialRepositoryLocationResolver; + private final FileSystem fileSystem; private final PathDatabase pathDatabase; private final Map pathById; @@ -46,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()); } - 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; @@ -66,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; @@ -141,6 +162,11 @@ public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocation .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/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 d77bfb3c63..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 @@ -17,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; @@ -53,7 +54,23 @@ class XmlRepositoryDAOTest { @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())); } 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-webapp/src/main/java/sonia/scm/ScmContextListener.java b/scm-webapp/src/main/java/sonia/scm/ScmContextListener.java index 5cb3f5dfa5..874052442d 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; @@ -77,7 +79,7 @@ public class ScmContextListener extends GuiceResteasyBootstrapServletContextList private final ClassLoader parent; private final Set plugins; private Injector injector; - + public interface Factory { ScmContextListener create(ClassLoader parent, Set plugins); } @@ -183,6 +185,18 @@ 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() { @@ -205,6 +219,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/boot/BootstrapContextFilter.java b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextFilter.java index aec8e2d653..cf4ec33f1e 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.ScmEventBus; import javax.servlet.FilterConfig; @@ -99,11 +97,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 3af8a76650..76aad46b77 100644 --- a/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/boot/BootstrapContextListener.java @@ -46,6 +46,7 @@ import sonia.scm.ScmEventBusModule; import sonia.scm.ScmInitializerModule; import sonia.scm.Stage; import sonia.scm.event.ScmEventBus; +import sonia.scm.migration.UpdateException; import sonia.scm.plugin.DefaultPluginLoader; import sonia.scm.plugin.Plugin; import sonia.scm.plugin.PluginException; @@ -54,6 +55,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; @@ -110,18 +112,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; } @@ -151,27 +141,59 @@ public class BootstrapContextListener implements ServletContextListener { } private void createContextListener(File pluginDirectory) { + ClassLoader cl; + Set plugins; + PluginLoader pluginLoader; + try { + 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) { @@ -402,7 +424,7 @@ public class BootstrapContextListener implements ServletContextListener { private ServletContext context; /** Field description */ - private ScmContextListener contextListener; + private ServletContextListener contextListener; /** Field description */ private boolean registered = false; 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/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/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/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/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/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/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");